Skip to content

feat(app): dashboard section navigation aids (TOC, view options, deep links)#2330

Closed
teeohhem wants to merge 19 commits into
mainfrom
dashboard-toc-navigation
Closed

feat(app): dashboard section navigation aids (TOC, view options, deep links)#2330
teeohhem wants to merge 19 commits into
mainfrom
dashboard-toc-navigation

Conversation

@teeohhem
Copy link
Copy Markdown
Contributor

@teeohhem teeohhem commented May 22, 2026

Summary

Adds three opt-in dashboard navigation aids for dashboards that have grown past comfortable scroll length. All three are off by default — existing dashboards look unchanged for users who never open the new menu.

  1. Hash anchors per section. Each DashboardContainer renders with id="container-<id>", and hovering a section header reveals a "copy link" icon that writes <current-url>#container-<id> to the clipboard (via the shared @/utils/clipboard helper). Loading the page with such a hash auto-scrolls to that section, auto-expanding it first.
  2. View-options menu in the toolbar. "Collapse all sections" / "Expand all sections" / "Show table of contents". Batch collapse/expand reuses the existing URL-synced collapse state (?collapsed=…&expanded=…), so a compact view is shareable like any other dashboard state.
  3. Sticky in-flow TOC rail. When toggled on, a 180px column sits next to the dashboard content as a Flex sibling (no overlay, tiles reflow), with one entry per section, scrollspy-driven active highlighting, and click-to-jump that also auto-expands. Hidden below the md breakpoint. Visibility is a per-user preference in useUserPreferences.

Core logic lives in src/hooks/useDashboardSectionNav.ts (scrollToContainer, copySectionLink, collapseAll, expandAll). New components are DashboardTOC and DashboardViewOptions. DashboardContainer gained an optional onCopyLink prop and a stable id attribute on its root.

Screenshots or video

Before
image

After

dashboardtoc.mp4

How to test on Vercel preview

Preview routes: `/dashboards/[id]` (any existing dashboard with 4+ sections)

Steps:

  1. Open a dashboard with at least four sections (group containers).
  2. Click the view-options icon in the toolbar (between "Edit Filters" and "Run"); verify the menu shows "Collapse all sections", "Expand all sections", and "Show table of contents".
  3. Click "Collapse all sections" — verify every section collapses to its header and the URL gains `?collapsed=…`.
  4. Click "Show table of contents" — verify a sticky rail appears on the right with one entry per section.
  5. Click any TOC entry — verify the page smooth-scrolls to that section and auto-expands it if collapsed.
  6. Hover any section header — verify a small link icon appears. Click it — verify the "Link copied" notification, paste the URL in a new tab, and verify the page loads and scrolls to that section.

References


Reviewer notes (pre-push fanout findings)

Ran `ce-correctness`, `ce-maintainability`, `ce-adversarial`, `ce-kieran-typescript`, and `ce-testing` reviewers in parallel before pushing. Triaged each finding as introduced/amplified/pre-existing.

Fixed before push (P1 / P2):

  • Hash-on-load effect now keys on `dashboard.id` (not a binary ref), so SPA navigation between dashboards re-arms the deep-link scroll, and the initial scroll is gated on containers being loaded.
  • `scrollToContainer` now uses double-rAF before measuring, so auto-expand-then-scroll on a collapsed section lands correctly after React commit + tile layout.
  • TOC label uses `container.title` for multi-tab containers (the tab bar replaces a single-title header in that case) and falls back to the first tab's title for single-tab containers.

Known follow-ups (P2, not fixed in this PR):

  • `useDashboardSectionNav` and `DBDashboardPage` both subscribe to `useQueryState('collapsed' | 'expanded')`. nuqs reads stay in sync, but consolidating the URL-collapse plumbing in one place would prevent future drift.
  • TOC entry hover styling uses inline `style` + `onMouseEnter/Leave`. A CSS module keyed on `data-active` would be cleaner and matches the convention used elsewhere in the repo.
  • `copySectionLink` includes the full current `window.location.search` in the copied URL — that intentionally captures the recipient's filter context, but means transient params (`highlightedTileId`, debug params) tag along. Worth a follow-up to filter.
  • `useScrollSpy` uses a global `[id^="container-"]` selector. Tightening to a scoped ref would be more robust against future ID collisions or drag-overlay clones.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 22, 2026

🦋 Changeset detected

Latest commit: 3b4f678

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Patch
@hyperdx/api Patch
@hyperdx/otel-collector Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment May 27, 2026 1:37pm

Request Review

@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label May 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 590 production lines changed (Tier 2 max: < 250)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 6
  • Production lines changed: 590 (+ 454 in test files, excluded from tier calculation)
  • Branch: dashboard-toc-navigation
  • Author: teeohhem

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

E2E Test Results

All tests passed • 190 passed • 3 skipped • 1201s

Status Count
✅ Passed 190
❌ Failed 0
⚠️ Flaky 5
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

Deep Review

✅ No critical issues found.

The diff adds three opt-in navigation aids behind feature flags / per-user preferences (tocVisible defaults false, onCopyLink is opt-in, view-options menu gated on containers.length > 0), so the "do nothing different by default" claim holds. Hook logic in useDashboardSectionNav.ts is covered by 9 unit tests including the rAF-driven scroll path and the clipboard success/failure branches, DashboardContainer's new id and copy-link wiring are covered for both single-tab and multi-tab headers, and the URL-state plumbing reuses the existing parseAsArrayOf(parseAsString) keys without changing their semantics. WidthProvider(RGL) already observes parent resizes, so the new Flex wrapper around the tile grid does not regress react-grid-layout when the TOC sibling appears.

🔵 P3 nitpicks (5)
  • packages/app/src/hooks/useDashboardSectionNav.ts:52scrollToContainer unconditionally calls expandContainer, which appends the target id to the URL expanded param even when the section is already expanded; the URL accumulates redundant entries on every TOC click or deep-link jump.
    • Fix: Skip the setUrlExpandedIds write when the id is already present and not in the collapsed set, or pass an "already expanded" hint from the caller.
  • packages/app/src/DBDashboardPage.tsx:1746 — The hash-on-load effect depends on the full dashboard object, so it re-attaches the hashchange listener on every dashboard refetch even though it only reads dashboard.id and dashboard.containers.
    • Fix: Narrow the dependency array to [dashboard?.id, dashboard?.containers, scrollToContainer] to avoid churn on unrelated dashboard field updates.
  • packages/app/src/DBDashboardPage.tsx:1757 — The hash regex captures (.+) and compares the raw fragment against c.id with no decodeURIComponent, so a deep link to an id with URL-unsafe characters would silently fail the existence check.
    • Fix: Wrap the captured group in decodeURIComponent before the some check; do the symmetric encodeURIComponent in copySectionLink.
  • packages/app/src/components/DashboardTOC.tsx:25containerSignature joins ids with |, so two id sets that differ only by |-vs-empty would produce equal signatures and skip the reinitialize call.
    • Fix: Use JSON.stringify(containers.map(c => c.id)) or a delimiter known to be illegal in ids.
  • packages/app/src/hooks/__tests__/useDashboardSectionNav.test.tsx:131 — The requestAnimationFrame spy invokes its callback synchronously, so the assertion passes whether the hook calls rAF once, twice, or zero times; the test does not actually defend the double-rAF contract that justifies the comment in the hook.
    • Fix: Count rAF invocations or use a manual scheduler that lets the test assert callbacks run in two distinct frames before scrollIntoView fires.

Reviewers (8): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, adversarial, agent-native.

Testing gaps:

  • No integration test exercises the Flex + tocVisible layout path in DBDashboardPage; the TOC sidebar render and the tocContainers label-fallback memo (tabs.length >= 2 ? c.title : tabs[0]?.title ?? c.title) are only covered indirectly.
  • The hash-on-load effect (initial-scroll re-arm on dashboard.id change, listener cleanup) has no direct test; current coverage relies on the hook's scrollToContainer test plus the regex inline in the page.

teeohhem added 4 commits May 22, 2026 12:33
Adds three opt-in helpers for navigating dashboards with many sections,
all off by default so existing dashboards look unchanged.

- Hash anchors per section: each DashboardContainer renders with
  id="container-<id>". Hovering a section header reveals a "copy link"
  icon that copies the current URL plus a #container-<id> fragment
  (using the shared @/utils/clipboard helper). Loading the page with
  such a hash auto-scrolls to that section, auto-expanding it first.
- View-options menu in the dashboard toolbar: "Collapse all sections"
  / "Expand all sections" / "Show table of contents" toggle. Batch
  collapse/expand reuse the existing URL-synced collapse state so the
  resulting view is shareable.
- Sticky in-flow TOC rail: when enabled via the view-options toggle, a
  180px column renders beside the dashboard content (Flex sibling, not
  an overlay) with one entry per section. useScrollSpy highlights the
  active section as the user scrolls; click jumps. Hidden below the md
  breakpoint. The "tocVisible" preference is per-user in localStorage.

Core hook lives in src/hooks/useDashboardSectionNav.ts and exposes
scrollToContainer, copySectionLink, collapseAll, and expandAll.
The menu's items (collapse-all / expand-all / show TOC) are all
section-scoped and behave as no-ops on dashboards with zero containers.
Hide the toolbar entry-point in that case so it does not surface
unhelpful UI on sectionless dashboards. The TOC itself already
short-circuits when containers is empty.
DBDashboardPage owned a subscription on the 'collapsed' and 'expanded'
URL params via useQueryState; useDashboardSectionNav independently
subscribed to the same two keys. The hook now receives setters from
the caller instead so DBDashboardPage is the sole subscriber per key.

Eliminates a potential interaction between concurrent nuqs subscribers
during initial hydration on the saved dashboard route, where the
expected post-hydration re-render to populate router.query.dashboardId
could otherwise be delayed. Behaviour of collapseAll / expandAll /
scrollToContainer is unchanged; tests updated to inject the setters.
karl-power
karl-power previously approved these changes May 26, 2026
@teeohhem teeohhem marked this pull request as draft May 26, 2026 13:29
Reverts DBDashboardPage.tsx to origin/main while keeping all other
dashboard-toc-navigation changes (new files, DashboardContainer id +
copy-link prop, useUserPreferences tocVisible field). If the saved
dashboard refresh bug disappears with this commit, the cause is
somewhere in the page-level wiring (hooks, Flex wrap, mount points,
hash effect); re-introduce piece by piece after confirming.
Smallest possible addition on top of the previous bisect commit
(DBDashboardPage at origin/main). If the saved-dashboard refresh bug
returns with just this single subscription, confirms the cause is the
Jotai atomWithStorage hydration racing with router.isReady transition.
useUserPreferences was confirmed safe in step 1. Adding the only other
top-level useEffect my changes introduced: the hash-on-load listener.
Body stripped of scrollToContainer dependency so this is the bare
effect+listener shape. If the bug returns, the cause is this effect's
interaction with router state during hydration.
useUserPreferences (step 1) and hash-on-load useEffect (step 2) both
confirmed safe. Now wrapping the dashboard content area in
<Flex gap='md' align='flex-start' mt='sm'> with a <Box flex={1} miw={0}>
inner wrap — the structural CSS change from my changes, no TOC sibling.
If the bug returns, the cause is this layout structure (likely the
flex+miw interaction with ReactGridLayout or with router-time effects).
useUserPreferences (step 1), hash useEffect (step 2), Flex wrap (step 3)
all confirmed safe. Last suspect: the useDashboardSectionNav hook call.
It's only useCallback wrappers but it's the only remaining piece of my
changes. If the bug returns, the cause is something subtle about the
hook (likely useCallback identity churn affecting downstream effects).
…Container dep)

Steps 1-4 individually safe. Hypothesis: the bug needs the FULL hash
effect with scrollToContainer in both body and dep array. If nuqs v1
setters are unstable across renders, scrollToContainer's identity
changes each render and this effect re-runs constantly, thrashing
hashchange listener attach/detach during the router hydration window.
If the bug returns with this commit, the cause is the effect+
identity-churn interaction.
Reverts the bisect debug commits' page-level changes back to the full
feature state (matches b2f5f6d's DBDashboardPage). Bisect inconclusive:
none of the individual additions reproduced the original bug in a
fresh-session test, and we've concluded the original report may have
been state-dependent (browser localStorage / React Query cache from
pre-fix builds). If the bug returns in this commit, the cause is in
the remaining surface area (tocVisible/toggleToc derivation, tocContainers
memo, DashboardViewOptions + DashboardTOC mounts, onCopyLink forwarding).
useScrollSpy's `reinitialize` callback in Mantine v9 changes identity
every render. With `reinitialize` in the effect's dep array the effect
re-fires each render → reinitialize schedules a state update inside
useScrollSpy → component re-renders → reinitialize gets a new identity
→ effect fires again. Infinite loop, 'Maximum update depth exceeded',
and React never reaches the post-hydration commit that would populate
`router.query.dashboardId`.

The visible symptom on the dashboard page: refreshing a saved-dashboard
URL leaves the page stuck rendering the 'Temporary Dashboard' banner,
and sidebar nav clicks register but don't navigate. Manifests only when
`tocVisible` is true in user preferences (i.e. after the user toggled
the TOC on once); fresh sessions never hit it.

Fix: drop `reinitialize` from the dep array. The effect still re-runs
when the container id signature changes, which is what we actually need.
Toggling the sticky TOC mounts/unmounts a 180px Flex sibling, shrinking
or growing the tile-grid column on the left. ReactGridLayout's
WidthProvider listens for window resize events to recompute column
widths; without one of those, RGL keeps the old pixel widths, so tiles
overflow under the TOC when toggling it on and leave a gap on the
right when toggling it off. Persists until the next full page refresh.

Fix: dispatch a synthetic 'resize' event whenever tocVisible flips so
WidthProvider re-measures and re-lays out the grid against the new
available width.
The app layout puts page content inside
`<div id="app-content-scroll-container" style="overflow-y: scroll">`,
so the dashboard scrolls inside that inner div — the window does not
scroll at all. Mantine's `useScrollSpy` defaults to listening on
`window`, so it never received scroll events from the dashboard and
the active-section indicator (the accent left-border on TOC entries)
stayed pinned to the first section forever.

Click navigation worked because that uses scrollIntoView on a known
element id, no scroll listener needed.

Fix: resolve #app-content-scroll-container after mount and pass it as
useScrollSpy's `scrollHost` so the spy listens on the right element.
useScrollSpy picks the section whose top is closest to the viewport
top. That has a dead zone: when the user clicks a section that is
already visible but cannot be scroll-pinned at the top (e.g. the last
section in a short dashboard where the bottom hits the scroll end
before the section's top reaches the viewport top), the spy never
updates and the highlight stays on whatever was previously active.

Fix: treat clicks as explicit user intent that overrides the spy.
On click, set a clickedId; the entry highlights immediately. Wait
~700ms for the smooth-scroll triggered by the click to settle, then
arm a one-time scroll listener that clears the override on the next
user-initiated scroll. Result: clicks always highlight what was
clicked; the spy seamlessly resumes once the user scrolls manually.
Replaces the Mantine useScrollSpy + click-override + scroll-host plumbing
with a single IntersectionObserver effect. Active = section with the
highest intersection ratio with the viewport, computed against the
dashboard's actual scroll container (#app-content-scroll-container).

Why this is better:

- One rule replaces three layers of workarounds:
  * useScrollSpy reinitialize identity churn (caused an infinite loop
    that suppressed the post-hydration router commit on the dashboard
    page; previously fixed by removing reinitialize from deps).
  * useScrollSpy listening on window when the layout actually scrolls
    inside an inner div (previously fixed by passing scrollHost).
  * 'click-overrides-spy' timer + one-shot scroll listener to paper
    over the spy's 'closest-to-viewport-top' dead zone when the
    clicked section can't be pinned at the top.
  All three concerns disappear: IntersectionObserver tracks visibility
  directly, the clicked section is naturally most-visible after
  scrollIntoView lands, and the observer.root is the scroll container.

- Matches user intuition. 'Highlight what I'm looking at' = the section
  with the most pixels on screen, not 'whichever heading top is closest
  to viewport top'.

- Fewer hooks (useState x1, useEffect x1) replaces (useState x2,
  useEffect x3, useRef x1, useCallback x1, useScrollSpy x1). ~40
  fewer lines including the comment block.

Tests updated: jsdom does not implement IntersectionObserver, so the
suite installs a tiny fake that records the most recent observer and
exposes a manual `trigger` for asserting active-state transitions
across simulated visibility changes.
@teeohhem teeohhem closed this May 27, 2026
@teeohhem teeohhem deleted the dashboard-toc-navigation branch May 27, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants