Move section collapse state to URL query params (#1958)

## Summary
- Section expand/collapse is now tracked in URL query params (`collapsed`/`expanded`) instead of persisting to the DB on every chevron click
- The DB-stored `collapsed` field on `DashboardContainer` becomes the default fallback — what viewers see when opening a dashboard fresh (no URL state)
- Chevron click updates URL state only (per-viewer, shareable via link)
- "Collapse by Default" / "Expand by Default" menu action in the section header saves to the DB (via `setDashboard`), setting the default for all viewers
- `SectionHeader` now accepts separate `collapsed`/`defaultCollapsed` props and `onToggle`/`onToggleDefaultCollapsed` handlers
- Adds 7 unit tests for `SectionHeader`

Implements Drew's [review feedback on PR #1926](https://github.com/hyperdxio/hyperdx/pull/1926#discussion_r2966166505):
> IMO, expanding/collapsing should not be persisted to the dashboard UNLESS this option is used. [...] I think it would be nice to persist normal expand collapse states in the URL, and then fallback to the default state (saved in the DB based on this option here) if there is no URL state.

## Demo

![Section collapse via URL params demo](https://raw.githubusercontent.com/hyperdxio/hyperdx/feat/url-based-collapse-state/docs/assets/collapse-url-state-demo.gif)

Shows: expanded sections → chevron click collapses first section (URL updates to `?collapsed=...`) → menu shows "Collapse by Default" (DB action, separate from view state)

## Test plan
- [x] Open a dashboard with sections — collapse/expand via chevron click, verify URL updates (`?collapsed=...` / `?expanded=...`) without saving to DB
- [x] Copy the URL with collapse state and open in a new tab — verify sections reflect the URL state
- [x] Open the section menu and click "Collapse by Default" — verify this saves to DB (persists after page refresh without URL params)
- [x] Verify "Expand by Default" / "Collapse by Default" label reflects the DB default, not current view state
- [x] Run `yarn ci:unit --testPathPatterns='SectionHeader'` — all 7 tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alex Fedotyev 2026-03-24 06:58:30 -07:00 committed by GitHub
parent 47e1f565ee
commit 105a2f8970
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 303 additions and 60 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -13,7 +13,7 @@ import { useRouter } from 'next/router';
import { formatRelative } from 'date-fns'; import { formatRelative } from 'date-fns';
import produce from 'immer'; import produce from 'immer';
import { pick } from 'lodash'; import { pick } from 'lodash';
import { parseAsString, useQueryState } from 'nuqs'; import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import RGL, { WidthProvider } from 'react-grid-layout'; import RGL, { WidthProvider } from 'react-grid-layout';
import { useForm, useWatch } from 'react-hook-form'; import { useForm, useWatch } from 'react-hook-form';
@ -1101,38 +1101,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [editedTile, setEditedTile] = useState<undefined | Tile>(); const [editedTile, setEditedTile] = useState<undefined | Tile>();
const onAddTile = (containerId?: string) => {
// Auto-expand collapsed section so the new tile is visible
if (containerId && dashboard) {
const section = dashboard.containers?.find(s => s.id === containerId);
if (section?.collapsed) {
setDashboard(
produce(dashboard, draft => {
const s = draft.containers?.find(c => c.id === containerId);
if (s) s.collapsed = false;
}),
);
}
}
setEditedTile({
id: makeId(),
x: 0,
y: 0,
w: 8,
h: 10,
config: {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};
const sections = useMemo( const sections = useMemo(
() => dashboard?.containers ?? [], () => dashboard?.containers ?? [],
[dashboard?.containers], [dashboard?.containers],
); );
const hasSections = sections.length > 0; const hasSections = sections.length > 0;
// URL-based collapse state: tracks which sections the current viewer has
// explicitly collapsed/expanded. Falls back to the DB-stored default.
const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState(
'collapsed',
parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }),
);
const [urlExpandedIds, setUrlExpandedIds] = useQueryState(
'expanded',
parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }),
);
const collapsedIdSet = useMemo(
() => new Set(urlCollapsedIds ?? []),
[urlCollapsedIds],
);
const expandedIdSet = useMemo(
() => new Set(urlExpandedIds ?? []),
[urlExpandedIds],
);
const isSectionCollapsed = useCallback(
(section: DashboardContainer): boolean => {
// URL state takes precedence over DB default
if (collapsedIdSet.has(section.id)) return true;
if (expandedIdSet.has(section.id)) return false;
return section.collapsed ?? false;
},
[collapsedIdSet, expandedIdSet],
);
const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]);
const handleMoveTileToSection = useCallback( const handleMoveTileToSection = useCallback(
@ -1287,10 +1291,56 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
[dashboard, setDashboard], [dashboard, setDashboard],
); );
// Intentionally persists collapsed state to the server via setDashboard // Helpers for updating URL-based collapse sets via immer.
// (same pattern as tile drag/resize). This matches Grafana and Kibana const addToUrlSet = useCallback(
// behavior where collapsed state is saved with the dashboard for all viewers. (setter: typeof setUrlCollapsedIds, containerId: string) => {
setter(prev =>
produce(prev ?? [], draft => {
if (!draft.includes(containerId)) draft.push(containerId);
}),
);
},
[],
);
const removeFromUrlSet = useCallback(
(setter: typeof setUrlCollapsedIds, containerId: string) => {
setter(prev => {
const next = (prev ?? []).filter(id => id !== containerId);
return next.length > 0 ? next : null;
});
},
[],
);
// Toggle collapse in URL state only (per-viewer, shareable via link).
// Does NOT persist to DB — the DB `collapsed` field is the default.
const handleToggleSection = useCallback( const handleToggleSection = useCallback(
(containerId: string) => {
const section = dashboard?.containers?.find(s => s.id === containerId);
const currentlyCollapsed = section ? isSectionCollapsed(section) : false;
if (currentlyCollapsed) {
addToUrlSet(setUrlExpandedIds, containerId);
removeFromUrlSet(setUrlCollapsedIds, containerId);
} else {
addToUrlSet(setUrlCollapsedIds, containerId);
removeFromUrlSet(setUrlExpandedIds, containerId);
}
},
[
dashboard?.containers,
isSectionCollapsed,
addToUrlSet,
removeFromUrlSet,
setUrlCollapsedIds,
setUrlExpandedIds,
],
);
// Toggle the DB-stored default collapsed state (menu action).
// This changes what all viewers see by default when opening the dashboard.
const handleToggleDefaultCollapsed = useCallback(
(containerId: string) => { (containerId: string) => {
if (!dashboard) return; if (!dashboard) return;
setDashboard( setDashboard(
@ -1303,6 +1353,28 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
[dashboard, setDashboard], [dashboard, setDashboard],
); );
const onAddTile = (containerId?: string) => {
// Auto-expand collapsed section via URL state so the new tile is visible
if (containerId) {
const section = dashboard?.containers?.find(s => s.id === containerId);
if (section && isSectionCollapsed(section)) {
handleToggleSection(containerId);
}
}
setEditedTile({
id: makeId(),
x: 0,
y: 0,
w: 8,
h: 10,
config: {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};
const handleAddSection = useCallback(() => { const handleAddSection = useCallback(() => {
if (!dashboard) return; if (!dashboard) return;
setDashboard( setDashboard(
@ -1752,26 +1824,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
<SectionHeader <SectionHeader
section={section} section={section}
tileCount={sectionTiles.length} tileCount={sectionTiles.length}
collapsed={isSectionCollapsed(section)}
defaultCollapsed={section.collapsed ?? false}
onToggle={() => handleToggleSection(section.id)} onToggle={() => handleToggleSection(section.id)}
onToggleDefaultCollapsed={() =>
handleToggleDefaultCollapsed(section.id)
}
onRename={newTitle => onRename={newTitle =>
handleRenameSection(section.id, newTitle) handleRenameSection(section.id, newTitle)
} }
onDelete={() => handleDeleteSection(section.id)} onDelete={() => handleDeleteSection(section.id)}
onAddTile={() => onAddTile(section.id)} onAddTile={() => onAddTile(section.id)}
/> />
{!section.collapsed && sectionTiles.length > 0 && ( {!isSectionCollapsed(section) &&
<ReactGridLayout sectionTiles.length > 0 && (
layout={sectionTiles.map(tileToLayoutItem)} <ReactGridLayout
containerPadding={[0, 0]} layout={sectionTiles.map(tileToLayoutItem)}
onLayoutChange={sectionLayoutChangeHandlers.get( containerPadding={[0, 0]}
section.id, onLayoutChange={sectionLayoutChangeHandlers.get(
)} section.id,
cols={24} )}
rowHeight={32} cols={24}
> rowHeight={32}
{sectionTiles.map(renderTileComponent)} >
</ReactGridLayout> {sectionTiles.map(renderTileComponent)}
)} </ReactGridLayout>
)}
</div> </div>
); );
})} })}

View file

@ -13,14 +13,24 @@ import {
export default function SectionHeader({ export default function SectionHeader({
section, section,
tileCount, tileCount,
collapsed,
defaultCollapsed,
onToggle, onToggle,
onToggleDefaultCollapsed,
onRename, onRename,
onDelete, onDelete,
onAddTile, onAddTile,
}: { }: {
section: DashboardContainer; section: DashboardContainer;
tileCount: number; tileCount: number;
/** Effective collapsed state (URL state ?? DB default). */
collapsed: boolean;
/** The DB-stored default collapsed state. */
defaultCollapsed: boolean;
/** Toggle collapse in URL state (chevron click). */
onToggle: () => void; onToggle: () => void;
/** Toggle the DB-stored default collapsed state (menu action). */
onToggleDefaultCollapsed?: () => void;
onRename?: (newTitle: string) => void; onRename?: (newTitle: string) => void;
onDelete?: () => void; onDelete?: () => void;
onAddTile?: () => void; onAddTile?: () => void;
@ -31,7 +41,7 @@ export default function SectionHeader({
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const showControls = hovered || menuOpen; const showControls = hovered || menuOpen;
const hasMenuControls = onDelete != null; const hasMenuControls = onDelete != null || onToggleDefaultCollapsed != null;
const handleSaveRename = () => { const handleSaveRename = () => {
const trimmed = editedTitle.trim(); const trimmed = editedTitle.trim();
@ -81,13 +91,13 @@ export default function SectionHeader({
} }
role="button" role="button"
tabIndex={editing ? undefined : 0} tabIndex={editing ? undefined : 0}
aria-expanded={!section.collapsed} aria-expanded={!collapsed}
aria-label={`Toggle ${section.title} section`} aria-label={`Toggle ${section.title} section`}
> >
<IconChevronRight <IconChevronRight
size={16} size={16}
style={{ style={{
transform: section.collapsed ? 'rotate(0deg)' : 'rotate(90deg)', transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 150ms ease', transition: 'transform 150ms ease',
flexShrink: 0, flexShrink: 0,
color: 'var(--mantine-color-dimmed)', color: 'var(--mantine-color-dimmed)',
@ -127,7 +137,7 @@ export default function SectionHeader({
> >
{section.title} {section.title}
</Text> </Text>
{section.collapsed && tileCount > 0 && ( {collapsed && tileCount > 0 && (
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
({tileCount} {tileCount === 1 ? 'tile' : 'tiles'}) ({tileCount} {tileCount === 1 ? 'tile' : 'tiles'})
</Text> </Text>
@ -170,18 +180,21 @@ export default function SectionHeader({
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item {onToggleDefaultCollapsed && (
leftSection={ <Menu.Item
section.collapsed ? ( leftSection={
<IconEye size={14} /> defaultCollapsed ? (
) : ( <IconEye size={14} />
<IconEyeOff size={14} /> ) : (
) <IconEyeOff size={14} />
} )
onClick={onToggle} }
> onClick={onToggleDefaultCollapsed}
{section.collapsed ? 'Expand by Default' : 'Collapse by Default'} data-testid={`section-toggle-default-${section.id}`}
</Menu.Item> >
{defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'}
</Menu.Item>
)}
{onDelete && ( {onDelete && (
<> <>
<Menu.Divider /> <Menu.Divider />

View file

@ -0,0 +1,152 @@
import React from 'react';
import { DashboardContainer } from '@hyperdx/common-utils/dist/types';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SectionHeader from '../SectionHeader';
// Menu buttons have pointer-events:none when not hovered; skip that check.
const user = userEvent.setup({ pointerEventsCheck: 0 });
const makeSection = (
overrides: Partial<DashboardContainer> = {},
): DashboardContainer => ({
id: 'section-1',
type: 'section',
title: 'My Section',
collapsed: false,
...overrides,
});
describe('SectionHeader', () => {
it('renders section title and tile count when collapsed', () => {
renderWithMantine(
<SectionHeader
section={makeSection()}
tileCount={3}
collapsed={true}
defaultCollapsed={false}
onToggle={jest.fn()}
/>,
);
expect(screen.getByText('My Section')).toBeInTheDocument();
expect(screen.getByText('(3 tiles)')).toBeInTheDocument();
});
it('does not show tile count when expanded', () => {
renderWithMantine(
<SectionHeader
section={makeSection()}
tileCount={3}
collapsed={false}
defaultCollapsed={false}
onToggle={jest.fn()}
/>,
);
expect(screen.getByText('My Section')).toBeInTheDocument();
expect(screen.queryByText('(3 tiles)')).not.toBeInTheDocument();
});
it('calls onToggle (URL state) when chevron area is clicked', async () => {
const onToggle = jest.fn();
const onToggleDefaultCollapsed = jest.fn();
renderWithMantine(
<SectionHeader
section={makeSection()}
tileCount={2}
collapsed={false}
defaultCollapsed={false}
onToggle={onToggle}
onToggleDefaultCollapsed={onToggleDefaultCollapsed}
/>,
);
await user.click(
screen.getByRole('button', { name: /Toggle My Section section/i }),
);
expect(onToggle).toHaveBeenCalledTimes(1);
expect(onToggleDefaultCollapsed).not.toHaveBeenCalled();
});
it('shows "Collapse by Default" when DB default is expanded', async () => {
renderWithMantine(
<SectionHeader
section={makeSection()}
tileCount={0}
collapsed={false}
defaultCollapsed={false}
onToggle={jest.fn()}
onToggleDefaultCollapsed={jest.fn()}
onDelete={jest.fn()}
/>,
);
// Open the menu
await user.click(screen.getByTestId('section-menu-section-1'));
expect(await screen.findByText('Collapse by Default')).toBeInTheDocument();
});
it('shows "Expand by Default" when DB default is collapsed', async () => {
renderWithMantine(
<SectionHeader
section={makeSection({ collapsed: true })}
tileCount={0}
collapsed={true}
defaultCollapsed={true}
onToggle={jest.fn()}
onToggleDefaultCollapsed={jest.fn()}
onDelete={jest.fn()}
/>,
);
await user.click(screen.getByTestId('section-menu-section-1'));
expect(await screen.findByText('Expand by Default')).toBeInTheDocument();
});
it('calls onToggleDefaultCollapsed (DB state) from menu item', async () => {
const onToggle = jest.fn();
const onToggleDefaultCollapsed = jest.fn();
renderWithMantine(
<SectionHeader
section={makeSection()}
tileCount={0}
collapsed={false}
defaultCollapsed={false}
onToggle={onToggle}
onToggleDefaultCollapsed={onToggleDefaultCollapsed}
onDelete={jest.fn()}
/>,
);
await user.click(screen.getByTestId('section-menu-section-1'));
await user.click(await screen.findByText('Collapse by Default'));
expect(onToggleDefaultCollapsed).toHaveBeenCalledTimes(1);
expect(onToggle).not.toHaveBeenCalled();
});
it('uses collapsed prop for visual state independent of section.collapsed', () => {
// section.collapsed is false (DB default), but collapsed prop is true (URL override)
renderWithMantine(
<SectionHeader
section={makeSection({ collapsed: false })}
tileCount={5}
collapsed={true}
defaultCollapsed={false}
onToggle={jest.fn()}
/>,
);
// Should show tile count because collapsed=true (URL state takes precedence)
expect(screen.getByText('(5 tiles)')).toBeInTheDocument();
// The aria-expanded should reflect the effective state
expect(
screen.getByRole('button', { name: /Toggle My Section section/i }),
).toHaveAttribute('aria-expanded', 'false');
});
});