diff --git a/docs/assets/collapse-url-state-demo.gif b/docs/assets/collapse-url-state-demo.gif new file mode 100644 index 00000000..2411f9e9 Binary files /dev/null and b/docs/assets/collapse-url-state-demo.gif differ diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e464de04..6714b08f 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -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(); - 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 }) { 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 && ( - - {sectionTiles.map(renderTileComponent)} - - )} + {!isSectionCollapsed(section) && + sectionTiles.length > 0 && ( + + {sectionTiles.map(renderTileComponent)} + + )} ); })} diff --git a/packages/app/src/components/SectionHeader.tsx b/packages/app/src/components/SectionHeader.tsx index 092acf54..db0e1190 100644 --- a/packages/app/src/components/SectionHeader.tsx +++ b/packages/app/src/components/SectionHeader.tsx @@ -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`} > {section.title} - {section.collapsed && tileCount > 0 && ( + {collapsed && tileCount > 0 && ( ({tileCount} {tileCount === 1 ? 'tile' : 'tiles'}) @@ -170,18 +180,21 @@ export default function SectionHeader({ - - ) : ( - - ) - } - onClick={onToggle} - > - {section.collapsed ? 'Expand by Default' : 'Collapse by Default'} - + {onToggleDefaultCollapsed && ( + + ) : ( + + ) + } + onClick={onToggleDefaultCollapsed} + data-testid={`section-toggle-default-${section.id}`} + > + {defaultCollapsed ? 'Expand by Default' : 'Collapse by Default'} + + )} {onDelete && ( <> diff --git a/packages/app/src/components/__tests__/SectionHeader.test.tsx b/packages/app/src/components/__tests__/SectionHeader.test.tsx new file mode 100644 index 00000000..54d5a4a9 --- /dev/null +++ b/packages/app/src/components/__tests__/SectionHeader.test.tsx @@ -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 => ({ + id: 'section-1', + type: 'section', + title: 'My Section', + collapsed: false, + ...overrides, +}); + +describe('SectionHeader', () => { + it('renders section title and tile count when collapsed', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('My Section')).toBeInTheDocument(); + expect(screen.getByText('(3 tiles)')).toBeInTheDocument(); + }); + + it('does not show tile count when expanded', () => { + renderWithMantine( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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'); + }); +});