feat: Collapsible sections — authoring UX + DashboardContainer abstraction (#1926)

## Summary

Adds the authoring experience for dashboard sections (create, rename, delete, manage tiles) and introduces a polymorphic `DashboardContainer` abstraction that future-proofs the schema for tabs and groups.

Builds on #1900 (core collapsible sections mechanics). Closes #1897.

### Schema: `DashboardSection` → `DashboardContainer`

- Renamed `DashboardSectionSchema` → `DashboardContainerSchema` with a new `type` field (`'section'` for now, extensible to `'group'` / `'tab'` later)
- `sectionId` → `containerId` on tiles
- `sections` → `containers` on dashboards
- Updated across all packages: common-utils types, API Mongoose model, app types, import/export utils

### Authoring UX

| Action | How |
|---|---|
| **Create section** | Dashboard `...` overflow menu → "Add Section" |
| **Rename section** | Click the title text directly (Kibana-style inline editing) |
| **Delete section** | Hover section header → `...` → Delete Section (tiles become ungrouped, not deleted) |
| **Collapse/expand** | Click section header chevron |
| **Toggle default state** | Hover header → `...` → Collapse/Expand by Default |
| **Add tile to section** | Hover section header → `+` button opens tile editor pre-assigned to that section |
| **Move tile to section** | Hover tile → grid icon → pick target section from dropdown |
| **Move tile out** | Same dropdown → "(Ungrouped)" |

![Mar-18-2026 16-37-58](https://github.com/user-attachments/assets/79e23773-db49-401d-8453-40e0461f6147)


### UX polish (informed by best practices research)

- **Click-to-rename** — click section title text to edit inline (no menu navigation needed)
- **Hover-only controls** — `...` menu and `+` button only appear on section header hover, keeping view mode clean
- **"Add Section" demoted** — moved from equal-sized button to dashboard overflow menu (section creation is less frequent than tile creation)
- **"Move to Section" reordered** — placed before delete button for discoverability, uses `IconLayoutList` instead of `IconFolders`

### What's NOT in this PR (follow-up work)

- **Drag tiles between sections** — needs `react-dnd` custom drag layer; data model already supports it (`containerId` update)
- **Reorder sections** — needs sortable list library; data model supports it (array order)
- **Tabs / Groups** — new container types; just add to the `type` enum and build UIs

## Test plan

- [x] 30 unit tests pass (16 existing schema/grouping + 14 new authoring operations)
- [x] All 110 dashboard tests pass unchanged
- [x] ESLint clean
- [x] No TypeScript errors in changed files
- [x] Backward compatible — dashboards without containers render exactly as before

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Alex Fedotyev 2026-03-20 09:04:05 -07:00 committed by GitHub
parent 2fab76bfcd
commit b6cd088f18
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 653 additions and 105 deletions

View file

@ -31,7 +31,7 @@ export default mongoose.model<IDashboard>(
savedQuery: { type: String, required: false },
savedQueryLanguage: { type: String, required: false },
savedFilterValues: { type: mongoose.Schema.Types.Array, required: false },
sections: { type: mongoose.Schema.Types.Array, required: false },
containers: { type: mongoose.Schema.Types.Array, required: false },
},
{
timestamps: true,

View file

@ -28,6 +28,7 @@ import {
import {
AlertState,
ChartConfigWithDateRange,
DashboardContainer,
DashboardFilter,
DisplayType,
Filter,
@ -64,6 +65,7 @@ import {
IconDotsVertical,
IconDownload,
IconFilterEdit,
IconLayoutList,
IconPencil,
IconPlayerPlay,
IconRefresh,
@ -152,6 +154,8 @@ const Tile = forwardRef(
onEditClick,
onDeleteClick,
onUpdateChart,
onMoveToSection,
containers: availableSections,
granularity,
onTimeRangeSelect,
filters,
@ -172,6 +176,8 @@ const Tile = forwardRef(
onAddAlertClick?: () => void;
onDeleteClick: () => void;
onUpdateChart?: (chart: Tile) => void;
onMoveToSection?: (containerId: string | undefined) => void;
containers?: DashboardContainer[];
onSettled?: () => void;
granularity: SQLInterval | undefined;
onTimeRangeSelect: (start: Date, end: Date) => void;
@ -388,6 +394,40 @@ const Tile = forwardRef(
>
<IconPencil size={14} />
</ActionIcon>
{onMoveToSection &&
availableSections &&
availableSections.length > 0 && (
<Menu width={200} position="bottom-end">
<Menu.Target>
<ActionIcon
data-testid={`tile-move-section-button-${chart.id}`}
variant="subtle"
size="sm"
title="Move to Section"
>
<IconLayoutList size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Move to Section</Menu.Label>
{chart.containerId && (
<Menu.Item onClick={() => onMoveToSection(undefined)}>
(Ungrouped)
</Menu.Item>
)}
{availableSections
.filter(s => s.id !== chart.containerId)
.map(s => (
<Menu.Item
key={s.id}
onClick={() => onMoveToSection(s.id)}
>
{s.title}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
)}
<ActionIcon
data-testid={`tile-delete-button-${chart.id}`}
variant="subtle"
@ -403,12 +443,15 @@ const Tile = forwardRef(
alert,
alertIndicatorColor,
alertTooltip,
availableSections,
chart.config.displayType,
chart.id,
chart.containerId,
hovered,
onDeleteClick,
onDuplicateClick,
onEditClick,
onMoveToSection,
]);
const title = useMemo(
@ -1056,7 +1099,19 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const [editedTile, setEditedTile] = useState<undefined | Tile>();
const onAddTile = () => {
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,
@ -1067,16 +1122,33 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
...DEFAULT_CHART_CONFIG,
source: sources?.[0]?.id ?? '',
},
...(containerId ? { containerId } : {}),
});
};
const sections = useMemo(
() => dashboard?.sections ?? [],
[dashboard?.sections],
() => dashboard?.containers ?? [],
[dashboard?.containers],
);
const hasSections = sections.length > 0;
const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]);
const handleMoveTileToSection = useCallback(
(tileId: string, containerId: string | undefined) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
const tile = draft.tiles.find(t => t.id === tileId);
if (tile) {
if (containerId) tile.containerId = containerId;
else delete tile.containerId;
}
}),
);
},
[dashboard, setDashboard],
);
const renderTileComponent = useCallback(
(chart: Tile) => (
<Tile
@ -1161,6 +1233,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
});
}
}}
containers={sections}
onMoveToSection={containerId =>
handleMoveTileToSection(chart.id, containerId)
}
/>
),
[
@ -1176,6 +1252,8 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
whereLanguage,
onTimeRangeSelect,
filterQueries,
sections,
handleMoveTileToSection,
],
);
@ -1211,11 +1289,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
// (same pattern as tile drag/resize). This matches Grafana and Kibana
// behavior where collapsed state is saved with the dashboard for all viewers.
const handleToggleSection = useCallback(
(sectionId: string) => {
(containerId: string) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
const section = draft.sections?.find(s => s.id === sectionId);
const section = draft.containers?.find(s => s.id === containerId);
if (section) section.collapsed = !section.collapsed;
}),
);
@ -1223,14 +1301,73 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
[dashboard, setDashboard],
);
// Group tiles by section; orphaned tiles (sectionId not matching any
const handleAddSection = useCallback(() => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
if (!draft.containers) draft.containers = [];
draft.containers.push({
id: makeId(),
type: 'section',
title: 'New Section',
collapsed: false,
});
}),
);
}, [dashboard, setDashboard]);
const handleRenameSection = useCallback(
(containerId: string, newTitle: string) => {
if (!dashboard || !newTitle.trim()) return;
setDashboard(
produce(dashboard, draft => {
const section = draft.containers?.find(s => s.id === containerId);
if (section) section.title = newTitle.trim();
}),
);
},
[dashboard, setDashboard],
);
const handleDeleteSection = useCallback(
(containerId: string) => {
if (!dashboard) return;
setDashboard(
produce(dashboard, draft => {
// Find the bottom edge of existing ungrouped tiles so freed
// tiles are placed below them without collision.
const sectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
let maxUngroupedY = 0;
for (const tile of draft.tiles) {
if (!tile.containerId || !sectionIds.has(tile.containerId)) {
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
}
}
for (const tile of draft.tiles) {
if (tile.containerId === containerId) {
tile.y += maxUngroupedY;
delete tile.containerId;
}
}
draft.containers = draft.containers?.filter(
s => s.id !== containerId,
);
}),
);
},
[dashboard, setDashboard],
);
// Group tiles by section; orphaned tiles (containerId not matching any
// section) fall back to ungrouped to avoid silently hiding them.
const tilesBySectionId = useMemo(() => {
const tilesByContainerId = useMemo(() => {
const map = new Map<string, Tile[]>();
for (const section of sections) {
map.set(
section.id,
allTiles.filter(t => t.sectionId === section.id),
allTiles.filter(t => t.containerId === section.id),
);
}
return map;
@ -1240,10 +1377,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
() =>
hasSections
? allTiles.filter(
t => !t.sectionId || !tilesBySectionId.has(t.sectionId),
t => !t.containerId || !tilesByContainerId.has(t.containerId),
)
: allTiles,
[hasSections, allTiles, tilesBySectionId],
[hasSections, allTiles, tilesByContainerId],
);
const onUngroupedLayoutChange = useMemo(
@ -1254,11 +1391,11 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
const sectionLayoutChangeHandlers = useMemo(() => {
const map = new Map<string, (newLayout: RGL.Layout[]) => void>();
for (const section of sections) {
const tiles = tilesBySectionId.get(section.id) ?? [];
const tiles = tilesByContainerId.get(section.id) ?? [];
map.set(section.id, makeOnLayoutChange(tiles));
}
return map;
}, [sections, tilesBySectionId, makeOnLayoutChange]);
}, [sections, tilesByContainerId, makeOnLayoutChange]);
const deleteDashboard = useDeleteDashboard();
@ -1452,6 +1589,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
>
{hasTiles ? 'Import New Dashboard' : 'Import Dashboard'}
</Menu.Item>
<Menu.Item
data-testid="add-new-section-button"
leftSection={<IconLayoutList size={16} />}
onClick={handleAddSection}
>
Add Section
</Menu.Item>
<Menu.Divider />
<Menu.Item
data-testid="save-default-query-filters-menu-item"
@ -1607,13 +1751,18 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
</ReactGridLayout>
)}
{sections.map(section => {
const sectionTiles = tilesBySectionId.get(section.id) ?? [];
const sectionTiles = tilesByContainerId.get(section.id) ?? [];
return (
<div key={section.id}>
<SectionHeader
section={section}
tileCount={sectionTiles.length}
onToggle={() => handleToggleSection(section.id)}
onRename={newTitle =>
handleRenameSection(section.id, newTitle)
}
onDelete={() => handleDeleteSection(section.id)}
onAddTile={() => onAddTile(section.id)}
/>
{!section.collapsed && sectionTiles.length > 0 && (
<ReactGridLayout
@ -1651,7 +1800,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
variant={dashboard?.tiles.length === 0 ? 'primary' : 'secondary'}
mt="sm"
fw={400}
onClick={onAddTile}
onClick={() => onAddTile()}
w="100%"
>
+ Add New Tile

View file

@ -1,13 +1,14 @@
import {
DashboardContainerSchema,
DashboardSchema,
DashboardSectionSchema,
TileSchema,
} from '@hyperdx/common-utils/dist/types';
describe('DashboardSection schema', () => {
describe('DashboardContainer schema', () => {
it('validates a valid section', () => {
const result = DashboardSectionSchema.safeParse({
const result = DashboardContainerSchema.safeParse({
id: 'section-1',
type: 'section',
title: 'Infrastructure',
collapsed: false,
});
@ -15,8 +16,9 @@ describe('DashboardSection schema', () => {
});
it('validates a collapsed section', () => {
const result = DashboardSectionSchema.safeParse({
const result = DashboardContainerSchema.safeParse({
id: 'section-2',
type: 'section',
title: 'Database Metrics',
collapsed: true,
});
@ -24,7 +26,7 @@ describe('DashboardSection schema', () => {
});
it('rejects a section missing required fields', () => {
const result = DashboardSectionSchema.safeParse({
const result = DashboardContainerSchema.safeParse({
id: 'section-3',
// missing title and collapsed
});
@ -33,15 +35,17 @@ describe('DashboardSection schema', () => {
it('rejects a section with empty id or title', () => {
expect(
DashboardSectionSchema.safeParse({
DashboardContainerSchema.safeParse({
id: '',
type: 'section',
title: 'Valid',
collapsed: false,
}).success,
).toBe(false);
expect(
DashboardSectionSchema.safeParse({
DashboardContainerSchema.safeParse({
id: 'valid',
type: 'section',
title: '',
collapsed: false,
}).success,
@ -49,7 +53,7 @@ describe('DashboardSection schema', () => {
});
});
describe('Tile schema with sectionId', () => {
describe('Tile schema with containerId', () => {
const baseTile = {
id: 'tile-1',
x: 0,
@ -70,22 +74,22 @@ describe('Tile schema with sectionId', () => {
},
};
it('validates a tile without sectionId (backward compatible)', () => {
it('validates a tile without containerId (backward compatible)', () => {
const result = TileSchema.safeParse(baseTile);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sectionId).toBeUndefined();
expect(result.data.containerId).toBeUndefined();
}
});
it('validates a tile with sectionId', () => {
it('validates a tile with containerId', () => {
const result = TileSchema.safeParse({
...baseTile,
sectionId: 'section-1',
containerId: 'section-1',
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sectionId).toBe('section-1');
expect(result.data.containerId).toBe('section-1');
}
});
});
@ -102,27 +106,27 @@ describe('Dashboard schema with sections', () => {
const result = DashboardSchema.safeParse(baseDashboard);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections).toBeUndefined();
expect(result.data.containers).toBeUndefined();
}
});
it('validates a dashboard with empty sections array', () => {
const result = DashboardSchema.safeParse({
...baseDashboard,
sections: [],
containers: [],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections).toEqual([]);
expect(result.data.containers).toEqual([]);
}
});
it('rejects duplicate section IDs', () => {
const result = DashboardSchema.safeParse({
...baseDashboard,
sections: [
{ id: 's1', title: 'Section A', collapsed: false },
{ id: 's1', title: 'Section B', collapsed: true },
containers: [
{ id: 's1', type: 'section', title: 'Section A', collapsed: false },
{ id: 's1', type: 'section', title: 'Section B', collapsed: true },
],
});
expect(result.success).toBe(false);
@ -131,16 +135,21 @@ describe('Dashboard schema with sections', () => {
it('validates a dashboard with sections', () => {
const result = DashboardSchema.safeParse({
...baseDashboard,
sections: [
{ id: 's1', title: 'Infrastructure', collapsed: false },
{ id: 's2', title: 'Application', collapsed: true },
containers: [
{
id: 's1',
type: 'section',
title: 'Infrastructure',
collapsed: false,
},
{ id: 's2', type: 'section', title: 'Application', collapsed: true },
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections).toHaveLength(2);
expect(result.data.sections![0].collapsed).toBe(false);
expect(result.data.sections![1].collapsed).toBe(true);
expect(result.data.containers).toHaveLength(2);
expect(result.data.containers![0].collapsed).toBe(false);
expect(result.data.containers![1].collapsed).toBe(true);
}
});
@ -151,7 +160,7 @@ describe('Dashboard schema with sections', () => {
y: 0,
w: 8,
h: 10,
sectionId: 's1',
containerId: 's1',
config: {
source: 'source-1',
select: [
@ -169,19 +178,26 @@ describe('Dashboard schema with sections', () => {
const result = DashboardSchema.safeParse({
...baseDashboard,
tiles: [tile],
sections: [{ id: 's1', title: 'Infrastructure', collapsed: false }],
containers: [
{
id: 's1',
type: 'section',
title: 'Infrastructure',
collapsed: false,
},
],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.tiles[0].sectionId).toBe('s1');
expect(result.data.sections![0].title).toBe('Infrastructure');
expect(result.data.tiles[0].containerId).toBe('s1');
expect(result.data.containers![0].title).toBe('Infrastructure');
}
});
});
describe('section tile grouping logic', () => {
// Test the grouping logic used in DBDashboardPage
type SimpleTile = { id: string; sectionId?: string };
type SimpleTile = { id: string; containerId?: string };
type SimpleSection = { id: string; title: string; collapsed: boolean };
function groupTilesBySection(tiles: SimpleTile[], sections: SimpleSection[]) {
@ -189,12 +205,12 @@ describe('section tile grouping logic', () => {
for (const section of sections) {
bySectionId.set(
section.id,
tiles.filter(t => t.sectionId === section.id),
tiles.filter(t => t.containerId === section.id),
);
}
// Orphaned tiles (sectionId not matching any section) fall back to ungrouped
// Orphaned tiles (containerId not matching any section) fall back to ungrouped
const ungrouped = tiles.filter(
t => !t.sectionId || !bySectionId.has(t.sectionId),
t => !t.containerId || !bySectionId.has(t.containerId),
);
return { ungrouped, bySectionId };
}
@ -208,9 +224,9 @@ describe('section tile grouping logic', () => {
it('groups tiles by section correctly', () => {
const tiles: SimpleTile[] = [
{ id: 'a', sectionId: 's1' },
{ id: 'b', sectionId: 's2' },
{ id: 'c', sectionId: 's1' },
{ id: 'a', containerId: 's1' },
{ id: 'b', containerId: 's2' },
{ id: 'c', containerId: 's1' },
{ id: 'd' }, // ungrouped
];
const sections: SimpleSection[] = [
@ -225,7 +241,7 @@ describe('section tile grouping logic', () => {
});
it('handles sections with no tiles', () => {
const tiles: SimpleTile[] = [{ id: 'a', sectionId: 's1' }];
const tiles: SimpleTile[] = [{ id: 'a', containerId: 's1' }];
const sections: SimpleSection[] = [
{ id: 's1', title: 'Has tiles', collapsed: false },
{ id: 's2', title: 'Empty', collapsed: false },
@ -237,8 +253,8 @@ describe('section tile grouping logic', () => {
it('filters visible tiles correctly for lazy loading', () => {
const tiles: SimpleTile[] = [
{ id: 'a', sectionId: 's1' },
{ id: 'b', sectionId: 's2' },
{ id: 'a', containerId: 's1' },
{ id: 'b', containerId: 's2' },
{ id: 'c' },
];
const sections: SimpleSection[] = [
@ -250,7 +266,7 @@ describe('section tile grouping logic', () => {
sections.filter(s => s.collapsed).map(s => s.id),
);
const visibleTiles = tiles.filter(
t => !t.sectionId || !collapsedIds.has(t.sectionId),
t => !t.containerId || !collapsedIds.has(t.containerId),
);
expect(visibleTiles).toHaveLength(2);
@ -258,10 +274,10 @@ describe('section tile grouping logic', () => {
// Tile 'b' is in collapsed section s2 and should not be rendered
});
it('treats tiles with non-existent sectionId as ungrouped', () => {
it('treats tiles with non-existent containerId as ungrouped', () => {
const tiles: SimpleTile[] = [
{ id: 'a', sectionId: 's1' },
{ id: 'b', sectionId: 'deleted-section' },
{ id: 'a', containerId: 's1' },
{ id: 'b', containerId: 'deleted-section' },
{ id: 'c' },
];
const sections: SimpleSection[] = [
@ -274,3 +290,239 @@ describe('section tile grouping logic', () => {
expect(bySectionId.get('s1')).toHaveLength(1);
});
});
describe('section authoring operations', () => {
type SimpleTile = { id: string; containerId?: string };
type SimpleSection = { id: string; title: string; collapsed: boolean };
type SimpleDashboard = {
tiles: SimpleTile[];
containers?: SimpleSection[];
};
function addSection(dashboard: SimpleDashboard, section: SimpleSection) {
const containers = [...(dashboard.containers ?? []), section];
return { ...dashboard, containers };
}
function renameSection(
dashboard: SimpleDashboard,
containerId: string,
newTitle: string,
) {
const trimmed = newTitle.trim();
if (!trimmed) return dashboard;
return {
...dashboard,
containers: dashboard.containers?.map(s =>
s.id === containerId ? { ...s, title: trimmed } : s,
),
};
}
function deleteSection(dashboard: SimpleDashboard, containerId: string) {
return {
...dashboard,
containers: dashboard.containers?.filter(s => s.id !== containerId),
tiles: dashboard.tiles.map(t =>
t.containerId === containerId ? { ...t, containerId: undefined } : t,
),
};
}
function toggleSectionCollapsed(
dashboard: SimpleDashboard,
containerId: string,
) {
return {
...dashboard,
containers: dashboard.containers?.map(s =>
s.id === containerId ? { ...s, collapsed: !s.collapsed } : s,
),
};
}
function moveTileToSection(
dashboard: SimpleDashboard,
tileId: string,
containerId: string | undefined,
) {
return {
...dashboard,
tiles: dashboard.tiles.map(t =>
t.id === tileId ? { ...t, containerId } : t,
),
};
}
describe('add section', () => {
it('adds a section to a dashboard without sections', () => {
const dashboard: SimpleDashboard = { tiles: [] };
const result = addSection(dashboard, {
id: 's1',
title: 'New Section',
collapsed: false,
});
expect(result.containers).toHaveLength(1);
expect(result.containers![0]).toEqual({
id: 's1',
title: 'New Section',
collapsed: false,
});
});
it('appends to existing sections', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'First', collapsed: false }],
};
const result = addSection(dashboard, {
id: 's2',
title: 'Second',
collapsed: false,
});
expect(result.containers).toHaveLength(2);
expect(result.containers![1].id).toBe('s2');
});
});
describe('rename section', () => {
it('renames a section', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'Old Name', collapsed: false }],
};
const result = renameSection(dashboard, 's1', 'New Name');
expect(result.containers![0].title).toBe('New Name');
});
it('trims whitespace from new title', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'Old', collapsed: false }],
};
const result = renameSection(dashboard, 's1', ' Trimmed ');
expect(result.containers![0].title).toBe('Trimmed');
});
it('rejects empty title', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'Keep Me', collapsed: false }],
};
const result = renameSection(dashboard, 's1', ' ');
expect(result.containers![0].title).toBe('Keep Me');
});
it('does not affect other sections', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [
{ id: 's1', title: 'One', collapsed: false },
{ id: 's2', title: 'Two', collapsed: true },
],
};
const result = renameSection(dashboard, 's1', 'Updated');
expect(result.containers![0].title).toBe('Updated');
expect(result.containers![1].title).toBe('Two');
});
});
describe('delete section', () => {
it('removes the section', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [
{ id: 's1', title: 'Keep', collapsed: false },
{ id: 's2', title: 'Delete Me', collapsed: false },
],
};
const result = deleteSection(dashboard, 's2');
expect(result.containers).toHaveLength(1);
expect(result.containers![0].id).toBe('s1');
});
it('ungroups child tiles when section is deleted', () => {
const dashboard: SimpleDashboard = {
tiles: [
{ id: 'a', containerId: 's1' },
{ id: 'b', containerId: 's1' },
{ id: 'c', containerId: 's2' },
{ id: 'd' },
],
containers: [
{ id: 's1', title: 'Delete Me', collapsed: false },
{ id: 's2', title: 'Keep', collapsed: false },
],
};
const result = deleteSection(dashboard, 's1');
expect(result.containers).toHaveLength(1);
expect(result.tiles.find(t => t.id === 'a')?.containerId).toBeUndefined();
expect(result.tiles.find(t => t.id === 'b')?.containerId).toBeUndefined();
expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('s2');
expect(result.tiles.find(t => t.id === 'd')?.containerId).toBeUndefined();
});
it('handles deleting the last section', () => {
const dashboard: SimpleDashboard = {
tiles: [{ id: 'a', containerId: 's1' }],
containers: [{ id: 's1', title: 'Only One', collapsed: false }],
};
const result = deleteSection(dashboard, 's1');
expect(result.containers).toHaveLength(0);
expect(result.tiles[0].containerId).toBeUndefined();
});
});
describe('toggle default collapsed', () => {
it('toggles collapsed from false to true', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'Test', collapsed: false }],
};
const result = toggleSectionCollapsed(dashboard, 's1');
expect(result.containers![0].collapsed).toBe(true);
});
it('toggles collapsed from true to false', () => {
const dashboard: SimpleDashboard = {
tiles: [],
containers: [{ id: 's1', title: 'Test', collapsed: true }],
};
const result = toggleSectionCollapsed(dashboard, 's1');
expect(result.containers![0].collapsed).toBe(false);
});
});
describe('move tile to section', () => {
it('assigns a tile to a section', () => {
const dashboard: SimpleDashboard = {
tiles: [{ id: 'a' }, { id: 'b' }],
containers: [{ id: 's1', title: 'Target', collapsed: false }],
};
const result = moveTileToSection(dashboard, 'a', 's1');
expect(result.tiles.find(t => t.id === 'a')?.containerId).toBe('s1');
expect(result.tiles.find(t => t.id === 'b')?.containerId).toBeUndefined();
});
it('moves a tile between sections', () => {
const dashboard: SimpleDashboard = {
tiles: [{ id: 'a', containerId: 's1' }],
containers: [
{ id: 's1', title: 'From', collapsed: false },
{ id: 's2', title: 'To', collapsed: false },
],
};
const result = moveTileToSection(dashboard, 'a', 's2');
expect(result.tiles[0].containerId).toBe('s2');
});
it('ungroups a tile from a section', () => {
const dashboard: SimpleDashboard = {
tiles: [{ id: 'a', containerId: 's1' }],
containers: [{ id: 's1', title: 'Source', collapsed: false }],
};
const result = moveTileToSection(dashboard, 'a', undefined);
expect(result.tiles[0].containerId).toBeUndefined();
});
});
});

View file

@ -1,56 +1,202 @@
import { DashboardSection } from '@hyperdx/common-utils/dist/types';
import { Flex, Text } from '@mantine/core';
import { IconChevronRight } from '@tabler/icons-react';
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,
onToggle,
onRename,
onDelete,
onAddTile,
}: {
section: DashboardSection;
section: DashboardContainer;
tileCount: number;
onToggle: () => 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;
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}
onClick={onToggle}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}}
role="button"
tabIndex={0}
aria-expanded={!section.collapsed}
aria-label={`Toggle ${section.title} section`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
cursor: 'pointer',
borderBottom: '1px solid var(--mantine-color-dark-4)',
userSelect: 'none',
}}
data-testid={`section-header-${section.id}`}
>
<IconChevronRight
size={16}
style={{
transform: section.collapsed ? 'rotate(0deg)' : 'rotate(90deg)',
transition: 'transform 150ms ease',
flexShrink: 0,
color: 'var(--mantine-color-dimmed)',
}}
/>
<Text size="sm" fw={500}>
{section.title}
</Text>
{section.collapsed && tileCount > 0 && (
<Text size="xs" c="dimmed">
({tileCount} {tileCount === 1 ? 'tile' : 'tiles'})
</Text>
<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={!section.collapsed}
aria-label={`Toggle ${section.title} section`}
>
<IconChevronRight
size={16}
style={{
transform: section.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>
{section.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>
<Menu.Item
leftSection={
section.collapsed ? (
<IconEye size={14} />
) : (
<IconEyeOff size={14} />
)
}
onClick={onToggle}
>
{section.collapsed ? '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>
);

View file

@ -1,8 +1,8 @@
import { useCallback, useMemo, useState } from 'react';
import { parseAsJson, useQueryState } from 'nuqs';
import {
DashboardContainer,
DashboardFilter,
DashboardSection,
Filter,
SavedChartConfig,
SearchConditionLanguage,
@ -24,7 +24,7 @@ export type Tile = {
w: number;
h: number;
config: SavedChartConfig;
sectionId?: string;
containerId?: string;
};
export type Dashboard = {
@ -36,7 +36,7 @@ export type Dashboard = {
savedQuery?: string | null;
savedQueryLanguage?: SearchConditionLanguage | null;
savedFilterValues?: Filter[];
sections?: DashboardSection[];
containers?: DashboardContainer[];
};
const localDashboards = createEntityStore<Dashboard>('hdx-local-dashboards');

View file

@ -517,8 +517,8 @@ export function convertToDashboardTemplate(
}
}
if (input.sections) {
output.sections = structuredClone(input.sections);
if (input.containers) {
output.containers = structuredClone(input.containers);
}
return output;
@ -556,8 +556,8 @@ export function convertToDashboardDocument(
}
}
if (input.sections) {
output.sections = structuredClone(input.sections);
if (input.containers) {
output.containers = structuredClone(input.containers);
}
return output;

View file

@ -687,7 +687,7 @@ export const TileSchema = z.object({
w: z.number(),
h: z.number(),
config: SavedChartConfigSchema,
sectionId: z.string().optional(),
containerId: z.string().optional(),
});
export const TileTemplateSchema = TileSchema.extend({
@ -699,13 +699,14 @@ export const TileTemplateSchema = TileSchema.extend({
export type Tile = z.infer<typeof TileSchema>;
export const DashboardSectionSchema = z.object({
export const DashboardContainerSchema = z.object({
id: z.string().min(1),
type: z.enum(['section']),
title: z.string().min(1),
collapsed: z.boolean(),
});
export type DashboardSection = z.infer<typeof DashboardSectionSchema>;
export type DashboardContainer = z.infer<typeof DashboardContainerSchema>;
export const DashboardFilterType = z.enum(['QUERY_EXPRESSION']);
@ -739,14 +740,14 @@ export const DashboardSchema = z.object({
savedQuery: z.string().nullable().optional(),
savedQueryLanguage: SearchConditionLanguageSchema.nullable().optional(),
savedFilterValues: z.array(FilterSchema).optional(),
sections: z
.array(DashboardSectionSchema)
containers: z
.array(DashboardContainerSchema)
.refine(
sections => {
const ids = sections.map(s => s.id);
containers => {
const ids = containers.map(c => c.id);
return new Set(ids).size === ids.length;
},
{ message: 'Section IDs must be unique' },
{ message: 'Container IDs must be unique' },
)
.optional(),
});