hyperdx/packages/app/src/components/SectionHeader.tsx
Alex Fedotyev 105a2f8970
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)
2026-03-24 13:58:30 +00:00

216 lines
6.2 KiB
TypeScript

import { useState } from 'react';
import { DashboardContainer } from '@hyperdx/common-utils/dist/types';
import { ActionIcon, Flex, Input, Menu, Text } from '@mantine/core';
import {
IconChevronRight,
IconDotsVertical,
IconEye,
IconEyeOff,
IconPlus,
IconTrash,
} from '@tabler/icons-react';
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;
}) {
const [editing, setEditing] = useState(false);
const [editedTitle, setEditedTitle] = useState(section.title);
const [hovered, setHovered] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const showControls = hovered || menuOpen;
const hasMenuControls = onDelete != null || onToggleDefaultCollapsed != null;
const handleSaveRename = () => {
const trimmed = editedTitle.trim();
if (trimmed && trimmed !== section.title) {
onRename?.(trimmed);
} else {
setEditedTitle(section.title);
}
setEditing(false);
};
const handleTitleClick = (e: React.MouseEvent) => {
if (!onRename) return;
e.stopPropagation();
setEditedTitle(section.title);
setEditing(true);
};
return (
<Flex
align="center"
gap="xs"
px="sm"
py={4}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderBottom: '1px solid var(--mantine-color-dark-4)',
userSelect: 'none',
}}
data-testid={`section-header-${section.id}`}
>
<Flex
align="center"
gap="xs"
style={{ flex: 1, minWidth: 0, cursor: 'pointer' }}
onClick={editing ? undefined : onToggle}
onKeyDown={
editing
? undefined
: e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}
}
role="button"
tabIndex={editing ? undefined : 0}
aria-expanded={!collapsed}
aria-label={`Toggle ${section.title} section`}
>
<IconChevronRight
size={16}
style={{
transform: collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 150ms ease',
flexShrink: 0,
color: 'var(--mantine-color-dimmed)',
}}
/>
{editing ? (
<form
onSubmit={e => {
e.preventDefault();
handleSaveRename();
}}
onClick={e => e.stopPropagation()}
>
<Input
size="xs"
value={editedTitle}
onChange={e => setEditedTitle(e.currentTarget.value)}
onBlur={handleSaveRename}
onKeyDown={e => {
if (e.key === 'Escape') {
setEditedTitle(section.title);
setEditing(false);
}
}}
autoFocus
data-testid={`section-rename-input-${section.id}`}
/>
</form>
) : (
<>
<Text
size="sm"
fw={500}
truncate
onClick={onRename ? handleTitleClick : undefined}
style={onRename ? { cursor: 'text' } : undefined}
>
{section.title}
</Text>
{collapsed && tileCount > 0 && (
<Text size="xs" c="dimmed">
({tileCount} {tileCount === 1 ? 'tile' : 'tiles'})
</Text>
)}
</>
)}
</Flex>
{onAddTile && !editing && (
<ActionIcon
variant="subtle"
size="xs"
onClick={e => {
e.stopPropagation();
onAddTile();
}}
title="Add tile to section"
data-testid={`section-add-tile-${section.id}`}
style={{
opacity: showControls ? 1 : 0,
pointerEvents: showControls ? 'auto' : 'none',
}}
>
<IconPlus size={14} />
</ActionIcon>
)}
{hasMenuControls && !editing && (
<Menu width={200} position="bottom-end" onChange={setMenuOpen}>
<Menu.Target>
<ActionIcon
variant="subtle"
size="xs"
onClick={e => e.stopPropagation()}
data-testid={`section-menu-${section.id}`}
style={{
opacity: showControls ? 1 : 0,
pointerEvents: showControls ? 'auto' : 'none',
}}
>
<IconDotsVertical size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{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 />
<Menu.Item
leftSection={<IconTrash size={14} />}
color="red"
onClick={onDelete}
data-testid={`section-delete-${section.id}`}
>
Delete Section
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
)}
</Flex>
);
}