mirror of
https://github.com/hyperdxio/hyperdx
synced 2026-04-21 13:37:15 +00:00
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)" |  ### 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:
parent
2fab76bfcd
commit
b6cd088f18
7 changed files with 653 additions and 105 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue