Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a4ee6e6
feat(app): add dashboard section navigation aids
teeohhem May 22, 2026
3a70489
changeset
teeohhem May 22, 2026
51996ea
fix(app): hide dashboard view-options menu when no sections exist
teeohhem May 22, 2026
2b6e5ee
fix(app): consolidate useQueryState subscriptions to single owner
teeohhem May 22, 2026
b2f5f6d
Merge branch 'main' into dashboard-toc-navigation
teeohhem May 26, 2026
5444003
debug(app): revert DBDashboardPage changes to bisect routing regression
teeohhem May 26, 2026
f92b9d5
debug(app): bisect — add only top-level useUserPreferences call
teeohhem May 26, 2026
2e25b16
debug(app): bisect step 2 — add hash-on-load useEffect
teeohhem May 26, 2026
4757a58
debug(app): bisect step 3 — add Flex wrap around dashboard content
teeohhem May 26, 2026
0150772
debug(app): bisect step 4 — add useDashboardSectionNav call
teeohhem May 26, 2026
1f9d19b
debug(app): bisect step 5 — restore full hash effect (body + scrollTo…
teeohhem May 26, 2026
ca39549
feat(app): restore full dashboard section navigation feature
teeohhem May 26, 2026
08ece88
chore(common-utils): drop unused eslint-disable comment in flattenData
teeohhem May 26, 2026
ca375d9
fix(app): stop DashboardTOC reinitialize-effect infinite loop
teeohhem May 26, 2026
081eac0
fix(app): force RGL re-measure when TOC visibility changes
teeohhem May 26, 2026
14e0cdf
fix(app): wire DashboardTOC scrollspy to the actual scroll container
teeohhem May 26, 2026
cfdc60f
fix(app): treat TOC entry click as active-state override
teeohhem May 26, 2026
010f736
Merge branch 'main' into dashboard-toc-navigation
teeohhem May 27, 2026
3b4f678
refactor(app): rewrite DashboardTOC active-state on IntersectionObserver
teeohhem May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/easy-snakes-go.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hyperdx/app": patch
---

feat(app): dashboard section navigation aids (TOC, view options, deep links)
287 changes: 202 additions & 85 deletions packages/app/src/DBDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -1188,6 +1191,7 @@ function DashboardContainerRow({
onDeleteTab,
onRenameContainer,
onTabChange,
onCopyLink,
dragHandleProps,
makeLayoutChangeHandler,
tileToLayoutItem,
Expand All @@ -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;
Expand Down Expand Up @@ -1248,6 +1253,7 @@ function DashboardContainerRow({
onRenameTab={onRenameTab}
onDeleteTab={onDeleteTab}
onRename={onRenameContainer}
onCopyLink={onCopyLink}
dragHandleProps={dragHandleProps}
alertingTabIds={alertingTabIds}
>
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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<string | null>(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<MoveTarget[]>(() => {
const targets: MoveTarget[] = [];
Expand Down Expand Up @@ -2466,6 +2556,14 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<IconFilterEdit size={18} />
</ActionIcon>
</Tooltip>
{containers.length > 0 && (
<DashboardViewOptions
onCollapseAll={collapseAll}
onExpandAll={expandAll}
tocVisible={tocVisible}
onToggleToc={toggleToc}
/>
)}
<Button
data-testid="search-submit-button"
variant="primary"
Expand Down Expand Up @@ -2528,92 +2626,111 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</Flex>
</Paper>
)}
<Box mt="sm">
{dashboard != null && dashboard.tiles != null ? (
<ErrorBoundary
onError={console.error}
fallback={
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent">
An error occurred while rendering the dashboard.
</div>
}
>
<DashboardDndProvider
containers={containers}
onReorderContainers={handleReorderContainers}
<Flex gap="md" align="flex-start" mt="sm">
<Box flex={1} miw={0}>
{dashboard != null && dashboard.tiles != null ? (
<ErrorBoundary
onError={console.error}
fallback={
<div className="text-danger px-2 py-1 m-2 fs-7 font-monospace bg-danger-transparent">
An error occurred while rendering the dashboard.
</div>
}
>
{ungroupedTiles.length > 0 && (
<ReactGridLayout
layout={ungroupedTiles.map(tileToLayoutItem)}
containerPadding={[0, 0]}
onLayoutChange={onUngroupedLayoutChange}
cols={24}
rowHeight={32}
>
{ungroupedTiles.map(renderTileComponent)}
</ReactGridLayout>
)}
{containers.map(container => (
<SortableContainerWrapper
key={container.id}
containerId={container.id}
containerTitle={container.title}
>
{(dragHandleProps: DragHandleProps) => (
<DashboardContainerRow
container={container}
containerTiles={
tilesByContainerId.get(container.id) ?? []
}
isCollapsed={isContainerCollapsed(container)}
activeTabId={getActiveTabId(container)}
alertingTabIds={alertingTabIdsByContainer.get(
container.id,
)}
onToggleCollapse={() =>
handleToggleCollapse(container.id)
}
onToggleDefaultCollapsed={() =>
handleToggleDefaultCollapsed(container.id)
}
onToggleCollapsible={() =>
handleToggleCollapsible(container.id)
}
onToggleBordered={() =>
handleToggleBordered(container.id)
}
onDeleteContainer={action =>
handleDeleteContainer(container.id, action)
}
onAddTile={onAddTile}
onAddTab={() => {
const newTabId = handleAddTab(container.id);
if (newTabId) handleTabChange(container.id, newTabId);
}}
onRenameTab={(tabId, title) =>
handleRenameTab(container.id, tabId, title)
}
onDeleteTab={(tabId, action) =>
handleDeleteTab(container.id, tabId, action)
}
onRenameContainer={title =>
handleRenameContainer(container.id, title)
}
onTabChange={tabId =>
handleTabChange(container.id, tabId)
}
dragHandleProps={dragHandleProps}
makeLayoutChangeHandler={makeOnLayoutChange}
tileToLayoutItem={tileToLayoutItem}
renderTileComponent={renderTileComponent}
/>
)}
</SortableContainerWrapper>
))}
</DashboardDndProvider>
</ErrorBoundary>
) : null}
</Box>
<DashboardDndProvider
containers={containers}
onReorderContainers={handleReorderContainers}
>
{ungroupedTiles.length > 0 && (
<ReactGridLayout
layout={ungroupedTiles.map(tileToLayoutItem)}
containerPadding={[0, 0]}
onLayoutChange={onUngroupedLayoutChange}
cols={24}
rowHeight={32}
>
{ungroupedTiles.map(renderTileComponent)}
</ReactGridLayout>
)}
{containers.map(container => (
<SortableContainerWrapper
key={container.id}
containerId={container.id}
containerTitle={container.title}
>
{(dragHandleProps: DragHandleProps) => (
<DashboardContainerRow
container={container}
containerTiles={
tilesByContainerId.get(container.id) ?? []
}
isCollapsed={isContainerCollapsed(container)}
activeTabId={getActiveTabId(container)}
alertingTabIds={alertingTabIdsByContainer.get(
container.id,
)}
onToggleCollapse={() =>
handleToggleCollapse(container.id)
}
onToggleDefaultCollapsed={() =>
handleToggleDefaultCollapsed(container.id)
}
onToggleCollapsible={() =>
handleToggleCollapsible(container.id)
}
onToggleBordered={() =>
handleToggleBordered(container.id)
}
onDeleteContainer={action =>
handleDeleteContainer(container.id, action)
}
onAddTile={onAddTile}
onAddTab={() => {
const newTabId = handleAddTab(container.id);
if (newTabId) handleTabChange(container.id, newTabId);
}}
onRenameTab={(tabId, title) =>
handleRenameTab(container.id, tabId, title)
}
onDeleteTab={(tabId, action) =>
handleDeleteTab(container.id, tabId, action)
}
onRenameContainer={title =>
handleRenameContainer(container.id, title)
}
onTabChange={tabId =>
handleTabChange(container.id, tabId)
}
onCopyLink={() => copySectionLink(container.id)}
dragHandleProps={dragHandleProps}
makeLayoutChangeHandler={makeOnLayoutChange}
tileToLayoutItem={tileToLayoutItem}
renderTileComponent={renderTileComponent}
/>
)}
</SortableContainerWrapper>
))}
</DashboardDndProvider>
</ErrorBoundary>
) : null}
</Box>
{tocVisible && (
<Box
w={180}
visibleFrom="md"
style={{
position: 'sticky',
top: 16,
alignSelf: 'flex-start',
}}
>
<DashboardTOC
containers={tocContainers}
onJump={scrollToContainer}
/>
</Box>
)}
</Flex>
<Menu position="top" width={200}>
<Menu.Target>
<Button
Expand Down
Loading
Loading