diff --git a/.changeset/easy-snakes-go.md b/.changeset/easy-snakes-go.md new file mode 100644 index 0000000000..add3698af6 --- /dev/null +++ b/.changeset/easy-snakes-go.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat(app): dashboard section navigation aids (TOC, view options, deep links) diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index c911a30321..d3e67b5915 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -104,6 +104,8 @@ import { DashboardDndProvider, type DragHandleProps, } from '@/components/DashboardDndContext'; +import { DashboardTOC } from '@/components/DashboardTOC'; +import { DashboardViewOptions } from '@/components/DashboardViewOptions'; import EditTimeChartForm from '@/components/DBEditTimeChartForm'; import DBNumberChart from '@/components/DBNumberChart'; import DBTableChart from '@/components/DBTableChart'; @@ -122,6 +124,7 @@ import { import useDashboardContainers, { TabDeleteAction, } from '@/hooks/useDashboardContainers'; +import { useDashboardSectionNav } from '@/hooks/useDashboardSectionNav'; import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; import ChartContainer from './components/charts/ChartContainer'; @@ -1188,6 +1191,7 @@ function DashboardContainerRow({ onDeleteTab, onRenameContainer, onTabChange, + onCopyLink, dragHandleProps, makeLayoutChangeHandler, tileToLayoutItem, @@ -1209,6 +1213,7 @@ function DashboardContainerRow({ onDeleteTab: (tabId: string, action: TabDeleteAction) => void; onRenameContainer: (newTitle: string) => void; onTabChange: (tabId: string) => void; + onCopyLink: () => void; dragHandleProps: DragHandleProps; makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; tileToLayoutItem: (tile: Tile) => RGL.Layout; @@ -1248,6 +1253,7 @@ function DashboardContainerRow({ onRenameTab={onRenameTab} onDeleteTab={onDeleteTab} onRename={onRenameContainer} + onCopyLink={onCopyLink} dragHandleProps={dragHandleProps} alertingTabIds={alertingTabIds} > @@ -1611,6 +1617,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { () => dashboard?.containers ?? [], [dashboard?.containers], ); + // URL-based collapse state: tracks which containers the current viewer has // explicitly collapsed/expanded. Falls back to the DB-stored default. const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState( @@ -1621,6 +1628,58 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { 'expanded', parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }), ); + + // Section-level navigation helpers: scroll-to-section, deep-link copy, and + // batch collapse/expand. Consumed by the view-options menu (collapseAll / + // expandAll), the per-container header (copySectionLink), the TOC rail + // (scrollToContainer), and the hash-on-load effect (scrollToContainer). + // The hook takes the URL setters from above so there is only ever one + // useQueryState subscriber per `collapsed`/`expanded` key in this tree. + const { scrollToContainer, copySectionLink, collapseAll, expandAll } = + useDashboardSectionNav({ + containers, + setUrlCollapsedIds, + setUrlExpandedIds, + }); + + // Per-user preference for whether the sticky table-of-contents rail is shown. + // Defaults to false (off) so dashboard behavior is unchanged for users who + // never opt in via the view-options menu. + const { userPreferences, setUserPreference } = useUserPreferences(); + const tocVisible = userPreferences.tocVisible ?? false; + const toggleToc = useCallback( + () => setUserPreference({ tocVisible: !tocVisible }), + [tocVisible, setUserPreference], + ); + + // When the TOC mounts/unmounts the flex child holding the tile grid + // changes width. ReactGridLayout's WidthProvider listens for window + // resize events to re-measure, so dispatch one whenever `tocVisible` + // flips. Without this the RGL columns keep their old pixel widths and + // tiles either overflow under the TOC (when toggling on) or leave a + // gap (when toggling off) until the next page refresh. + useEffect(() => { + if (typeof window === 'undefined') return; + window.dispatchEvent(new Event('resize')); + }, [tocVisible]); + + // Lightweight projection of containers (id + label) for the TOC rail. + // The label mirrors what the user perceives as the section name: + // - 1-tab container: the visible header is the first tab's title (the + // header rename flow in DashboardContainer redirects to the first tab), + // so use that. + // - Multi-tab container: the visible header is the tab bar showing every + // tab; `container.title` is the section-level name that doesn't move + // when the active tab changes, so use that as the TOC label. + const tocContainers = useMemo( + () => + containers.map(c => { + const tabs = c.tabs ?? []; + const label = tabs.length >= 2 ? c.title : (tabs[0]?.title ?? c.title); + return { id: c.id, title: label }; + }), + [containers], + ); // Per-viewer active tab selection: `{ [containerId]: tabId }`. // Falls back to the first tab for any container not in the map. const [urlActiveTabs, setUrlActiveTabs] = useQueryState( @@ -1692,6 +1751,37 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { setUrlActiveTabs, ]); + // Hash-based deep links to dashboard sections (e.g. `#container-abc123`). + // Runs an initial scroll once per dashboard load (re-armed when the user + // navigates between dashboards in the SPA), then keeps a hashchange listener + // attached so in-page hash navigation also scrolls. Unknown containers + // (deleted sections, stale links) are silently ignored. + const initialHashScrolledForDashboardRef = useRef(null); + useEffect(() => { + if (typeof window === 'undefined' || !dashboard) return; + // Wait until containers are available so the existence check below can + // distinguish "unknown container" from "containers still loading". + if (!dashboard.containers || dashboard.containers.length === 0) return; + + const handleHash = () => { + const match = window.location.hash.match(/^#container-(.+)$/); + if (!match) return; + const containerId = match[1]; + const exists = dashboard.containers?.some(c => c.id === containerId); + if (!exists) return; + scrollToContainer(containerId); + }; + + // Fire the initial scroll once per dashboard.id so SPA navigation between + // dashboards (same component instance) re-arms the deep-link behavior. + if (initialHashScrolledForDashboardRef.current !== dashboard.id) { + initialHashScrolledForDashboardRef.current = dashboard.id; + handleHash(); + } + window.addEventListener('hashchange', handleHash); + return () => window.removeEventListener('hashchange', handleHash); + }, [dashboard, scrollToContainer]); + // Valid move targets: groups and individual tabs within groups const moveTargetContainers = useMemo(() => { const targets: MoveTarget[] = []; @@ -2466,6 +2556,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { + {containers.length > 0 && ( + + )}