mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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  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:
parent
47e1f565ee
commit
105a2f8970
4 changed files with 303 additions and 60 deletions
BIN
docs/assets/collapse-url-state-demo.gif
Normal file
BIN
docs/assets/collapse-url-state-demo.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
|
|
@ -13,7 +13,7 @@ import { useRouter } from 'next/router';
|
|||
import { formatRelative } from 'date-fns';
|
||||
import produce from 'immer';
|
||||
import { pick } from 'lodash';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import RGL, { WidthProvider } from 'react-grid-layout';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
|
|
@ -1101,38 +1101,42 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
|
||||
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(
|
||||
() => dashboard?.containers ?? [],
|
||||
[dashboard?.containers],
|
||||
);
|
||||
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 handleMoveTileToSection = useCallback(
|
||||
|
|
@ -1287,10 +1291,56 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
[dashboard, setDashboard],
|
||||
);
|
||||
|
||||
// Intentionally persists collapsed state to the server via setDashboard
|
||||
// (same pattern as tile drag/resize). This matches Grafana and Kibana
|
||||
// behavior where collapsed state is saved with the dashboard for all viewers.
|
||||
// Helpers for updating URL-based collapse sets via immer.
|
||||
const addToUrlSet = useCallback(
|
||||
(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(
|
||||
(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) => {
|
||||
if (!dashboard) return;
|
||||
setDashboard(
|
||||
|
|
@ -1303,6 +1353,28 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
[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(() => {
|
||||
if (!dashboard) return;
|
||||
setDashboard(
|
||||
|
|
@ -1752,26 +1824,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
|
|||
<SectionHeader
|
||||
section={section}
|
||||
tileCount={sectionTiles.length}
|
||||
collapsed={isSectionCollapsed(section)}
|
||||
defaultCollapsed={section.collapsed ?? false}
|
||||
onToggle={() => handleToggleSection(section.id)}
|
||||
onToggleDefaultCollapsed={() =>
|
||||
handleToggleDefaultCollapsed(section.id)
|
||||
}
|
||||
onRename={newTitle =>
|
||||
handleRenameSection(section.id, newTitle)
|
||||
}
|
||||
onDelete={() => handleDeleteSection(section.id)}
|
||||
onAddTile={() => onAddTile(section.id)}
|
||||
/>
|
||||
{!section.collapsed && sectionTiles.length > 0 && (
|
||||
<ReactGridLayout
|
||||
layout={sectionTiles.map(tileToLayoutItem)}
|
||||
containerPadding={[0, 0]}
|
||||
onLayoutChange={sectionLayoutChangeHandlers.get(
|
||||
section.id,
|
||||
)}
|
||||
cols={24}
|
||||
rowHeight={32}
|
||||
>
|
||||
{sectionTiles.map(renderTileComponent)}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
{!isSectionCollapsed(section) &&
|
||||
sectionTiles.length > 0 && (
|
||||
<ReactGridLayout
|
||||
layout={sectionTiles.map(tileToLayoutItem)}
|
||||
containerPadding={[0, 0]}
|
||||
onLayoutChange={sectionLayoutChangeHandlers.get(
|
||||
section.id,
|
||||
)}
|
||||
cols={24}
|
||||
rowHeight={32}
|
||||
>
|
||||
{sectionTiles.map(renderTileComponent)}
|
||||
</ReactGridLayout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,24 @@ import {
|
|||
export default function SectionHeader({
|
||||
section,
|
||||
tileCount,
|
||||
collapsed,
|
||||
defaultCollapsed,
|
||||
onToggle,
|
||||
onToggleDefaultCollapsed,
|
||||
onRename,
|
||||
onDelete,
|
||||
onAddTile,
|
||||
}: {
|
||||
section: DashboardContainer;
|
||||
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;
|
||||
/** Toggle the DB-stored default collapsed state (menu action). */
|
||||
onToggleDefaultCollapsed?: () => void;
|
||||
onRename?: (newTitle: string) => void;
|
||||
onDelete?: () => void;
|
||||
onAddTile?: () => void;
|
||||
|
|
@ -31,7 +41,7 @@ export default function SectionHeader({
|
|||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const showControls = hovered || menuOpen;
|
||||
const hasMenuControls = onDelete != null;
|
||||
const hasMenuControls = onDelete != null || onToggleDefaultCollapsed != null;
|
||||
|
||||
const handleSaveRename = () => {
|
||||
const trimmed = editedTitle.trim();
|
||||
|
|
@ -81,13 +91,13 @@ export default function SectionHeader({
|
|||
}
|
||||
role="button"
|
||||
tabIndex={editing ? undefined : 0}
|
||||
aria-expanded={!section.collapsed}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={`Toggle ${section.title} section`}
|
||||
>
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
style={{
|
||||
transform: section.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transition: 'transform 150ms ease',
|
||||
flexShrink: 0,
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
|
|
@ -127,7 +137,7 @@ export default function SectionHeader({
|
|||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
{section.collapsed && tileCount > 0 && (
|
||||
{collapsed && tileCount > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
({tileCount} {tileCount === 1 ? 'tile' : 'tiles'})
|
||||
</Text>
|
||||
|
|
@ -170,18 +180,21 @@ export default function SectionHeader({
|
|||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
section.collapsed ? (
|
||||
<IconEye size={14} />
|
||||
) : (
|
||||
<IconEyeOff size={14} />
|
||||
)
|
||||
}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{section.collapsed ? 'Expand by Default' : 'Collapse by Default'}
|
||||
</Menu.Item>
|
||||
{onToggleDefaultCollapsed && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
defaultCollapsed ? (
|
||||
<IconEye size={14} />
|
||||
) : (
|
||||
<IconEyeOff size={14} />
|
||||
)
|
||||
}
|
||||
onClick={onToggleDefaultCollapsed}
|
||||
data-testid={`section-toggle-default-${section.id}`}
|
||||
>
|
||||
{defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{onDelete && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
|
|
|
|||
152
packages/app/src/components/__tests__/SectionHeader.test.tsx
Normal file
152
packages/app/src/components/__tests__/SectionHeader.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue