Skip to content

Commit fd8799e

Browse files
committed
feat: Implement multi-tab container support
Add parentId to DashboardContainerSchema so individual tabs can reference their parent tab container. Each tab is a real container with its own ID, so tiles reference the child tab's containerId. Tab container UX: - Creating a tab container auto-creates an initial "Tab 1" child - "+" button in tab bar adds new tabs - Double-click tab label to rename inline - Close button (x) on each tab removes it (tiles migrate to first remaining tab; last tab cannot be deleted) - Switching tabs shows/hides tiles for the active tab - activeTabId on parent container tracks which tab is selected - Delete tab container removes all child tabs and ungroupes tiles Move-to-section dropdown shows child tabs as valid targets (excludes parent tab sets). moveTargetContainers memo filters appropriately. TabContainer component uses render-prop pattern: children receives activeTabId to render only the active tab's tiles and drop zone.
1 parent 8857503 commit fd8799e

File tree

3 files changed

+360
-89
lines changed

3 files changed

+360
-89
lines changed

packages/app/src/DBDashboardPage.tsx

Lines changed: 203 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import { notifications } from '@mantine/notifications';
6161
import {
6262
IconArrowsMaximize,
6363
IconBell,
64+
IconBoxMultiple,
6465
IconChartBar,
6566
IconCopy,
6667
IconDeviceFloppy,
@@ -1164,10 +1165,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
11641165
});
11651166
};
11661167

1168+
// Top-level containers: exclude child tabs (those with parentId)
11671169
const sections = useMemo(
1170+
() => (dashboard?.containers ?? []).filter(c => !c.parentId),
1171+
[dashboard?.containers],
1172+
);
1173+
1174+
// All containers (including child tabs) for lookups
1175+
const allContainers = useMemo(
11681176
() => dashboard?.containers ?? [],
11691177
[dashboard?.containers],
11701178
);
1179+
1180+
// Valid move targets: sections, groups, and child tabs (not parent tab sets)
1181+
const moveTargetContainers = useMemo(
1182+
() =>
1183+
allContainers.filter(
1184+
c => !(c.type === 'tab' && !c.parentId), // exclude parent tab sets
1185+
),
1186+
[allContainers],
1187+
);
1188+
11711189
const hasContainers = sections.length > 0;
11721190
// Keep backward-compatible alias
11731191
const hasSections = hasContainers;
@@ -1323,7 +1341,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
13231341
});
13241342
}
13251343
}}
1326-
containers={sections}
1344+
containers={moveTargetContainers}
13271345
onMoveToSection={containerId =>
13281346
handleMoveTileToSection(chart.id, containerId)
13291347
}
@@ -1344,7 +1362,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
13441362
whereLanguage,
13451363
onTimeRangeSelect,
13461364
filterQueries,
1347-
sections,
1365+
moveTargetContainers,
13481366
handleMoveTileToSection,
13491367
selectedTileIds,
13501368
handleTileSelect,
@@ -1406,12 +1424,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
14061424
setDashboard(
14071425
produce(dashboard, draft => {
14081426
if (!draft.containers) draft.containers = [];
1427+
const containerId = makeId();
14091428
draft.containers.push({
1410-
id: makeId(),
1429+
id: containerId,
14111430
type,
14121431
title: titles[type],
14131432
collapsed: false,
14141433
});
1434+
// Tab containers get an initial child tab
1435+
if (type === 'tab') {
1436+
const firstTabId = makeId();
1437+
draft.containers.push({
1438+
id: firstTabId,
1439+
type: 'tab',
1440+
title: 'Tab 1',
1441+
collapsed: false,
1442+
parentId: containerId,
1443+
});
1444+
// Set the initial active tab
1445+
const parent = draft.containers.find(c => c.id === containerId);
1446+
if (parent) parent.activeTabId = firstTabId;
1447+
}
14151448
}),
14161449
);
14171450
},
@@ -1463,23 +1496,31 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
14631496

14641497
setDashboard(
14651498
produce(dashboard, draft => {
1466-
const sectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
1499+
// Collect IDs to delete: the container + any child tabs
1500+
const childIds = new Set(
1501+
(draft.containers ?? [])
1502+
.filter(c => c.parentId === containerId)
1503+
.map(c => c.id),
1504+
);
1505+
const idsToDelete = new Set([containerId, ...childIds]);
1506+
1507+
const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
14671508
let maxUngroupedY = 0;
14681509
for (const tile of draft.tiles) {
1469-
if (!tile.containerId || !sectionIds.has(tile.containerId)) {
1510+
if (!tile.containerId || !allSectionIds.has(tile.containerId)) {
14701511
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
14711512
}
14721513
}
14731514

14741515
for (const tile of draft.tiles) {
1475-
if (tile.containerId === containerId) {
1516+
if (tile.containerId && idsToDelete.has(tile.containerId)) {
14761517
tile.y += maxUngroupedY;
14771518
delete tile.containerId;
14781519
}
14791520
}
14801521

14811522
draft.containers = draft.containers?.filter(
1482-
s => s.id !== containerId,
1523+
s => !idsToDelete.has(s.id),
14831524
);
14841525
}),
14851526
);
@@ -1501,18 +1542,106 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
15011542
[dashboard, setDashboard],
15021543
);
15031544

1504-
// Group tiles by section; orphaned tiles (containerId not matching any
1505-
// section) fall back to ungrouped to avoid silently hiding them.
1545+
// --- Tab management ---
1546+
1547+
const handleAddTab = useCallback(
1548+
(parentId: string) => {
1549+
if (!dashboard) return;
1550+
const siblings =
1551+
dashboard.containers?.filter(c => c.parentId === parentId) ?? [];
1552+
const newTabId = makeId();
1553+
setDashboard(
1554+
produce(dashboard, draft => {
1555+
if (!draft.containers) draft.containers = [];
1556+
draft.containers.push({
1557+
id: newTabId,
1558+
type: 'tab',
1559+
title: `Tab ${siblings.length + 1}`,
1560+
collapsed: false,
1561+
parentId,
1562+
});
1563+
// Auto-switch to the new tab
1564+
const parent = draft.containers.find(c => c.id === parentId);
1565+
if (parent) parent.activeTabId = newTabId;
1566+
}),
1567+
);
1568+
},
1569+
[dashboard, setDashboard],
1570+
);
1571+
1572+
const handleRenameTab = useCallback(
1573+
(tabId: string, newTitle: string) => {
1574+
if (!dashboard || !newTitle.trim()) return;
1575+
setDashboard(
1576+
produce(dashboard, draft => {
1577+
const tab = draft.containers?.find(c => c.id === tabId);
1578+
if (tab) tab.title = newTitle.trim();
1579+
}),
1580+
);
1581+
},
1582+
[dashboard, setDashboard],
1583+
);
1584+
1585+
const handleDeleteTab = useCallback(
1586+
(tabId: string) => {
1587+
if (!dashboard) return;
1588+
const tab = dashboard.containers?.find(c => c.id === tabId);
1589+
if (!tab?.parentId) return;
1590+
const parentId = tab.parentId;
1591+
const siblings =
1592+
dashboard.containers?.filter(
1593+
c => c.parentId === parentId && c.id !== tabId,
1594+
) ?? [];
1595+
// Don't delete the last tab
1596+
if (siblings.length === 0) return;
1597+
1598+
setDashboard(
1599+
produce(dashboard, draft => {
1600+
// Move tiles from deleted tab to first remaining sibling
1601+
const targetId = siblings[0].id;
1602+
for (const tile of draft.tiles) {
1603+
if (tile.containerId === tabId) {
1604+
tile.containerId = targetId;
1605+
}
1606+
}
1607+
// Remove the tab
1608+
draft.containers = draft.containers?.filter(c => c.id !== tabId);
1609+
// Update parent's activeTabId if it pointed to deleted tab
1610+
const parent = draft.containers?.find(c => c.id === parentId);
1611+
if (parent?.activeTabId === tabId) {
1612+
parent.activeTabId = targetId;
1613+
}
1614+
}),
1615+
);
1616+
},
1617+
[dashboard, setDashboard],
1618+
);
1619+
1620+
const handleTabChange = useCallback(
1621+
(parentId: string, tabId: string) => {
1622+
if (!dashboard) return;
1623+
setDashboard(
1624+
produce(dashboard, draft => {
1625+
const parent = draft.containers?.find(c => c.id === parentId);
1626+
if (parent) parent.activeTabId = tabId;
1627+
}),
1628+
);
1629+
},
1630+
[dashboard, setDashboard],
1631+
);
1632+
1633+
// Group tiles by container (including child tabs).
1634+
// Orphaned tiles (containerId not matching any container) become ungrouped.
15061635
const tilesByContainerId = useMemo(() => {
15071636
const map = new Map<string, Tile[]>();
1508-
for (const section of sections) {
1637+
for (const c of allContainers) {
15091638
map.set(
1510-
section.id,
1511-
allTiles.filter(t => t.containerId === section.id),
1639+
c.id,
1640+
allTiles.filter(t => t.containerId === c.id),
15121641
);
15131642
}
15141643
return map;
1515-
}, [sections, allTiles]);
1644+
}, [allContainers, allTiles]);
15161645

15171646
const ungroupedTiles = useMemo(
15181647
() =>
@@ -1524,6 +1653,19 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
15241653
[hasSections, allTiles, tilesByContainerId],
15251654
);
15261655

1656+
// Child tabs grouped by parent ID
1657+
const tabsByParentId = useMemo(() => {
1658+
const map = new Map<string, DashboardContainer[]>();
1659+
for (const c of allContainers) {
1660+
if (c.parentId) {
1661+
const list = map.get(c.parentId) ?? [];
1662+
list.push(c);
1663+
map.set(c.parentId, list);
1664+
}
1665+
}
1666+
return map;
1667+
}, [allContainers]);
1668+
15271669
const onUngroupedLayoutChange = useMemo(
15281670
() => makeOnLayoutChange(ungroupedTiles),
15291671
[makeOnLayoutChange, ungroupedTiles],
@@ -1898,7 +2040,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
18982040
}
18992041
>
19002042
<DashboardDndProvider
1901-
containers={sections}
2043+
containers={moveTargetContainers}
19022044
onMoveTileToSection={handleMoveTileToSection}
19032045
onReorderSections={handleReorderSections}
19042046
>
@@ -1968,6 +2110,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
19682110
}
19692111

19702112
if (container.type === 'tab') {
2113+
const childTabs = tabsByParentId.get(container.id) ?? [];
2114+
const activeTabId =
2115+
container.activeTabId ?? childTabs[0]?.id;
2116+
19712117
return (
19722118
<SortableSectionWrapper
19732119
key={container.id}
@@ -1977,41 +2123,49 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
19772123
{(dragHandleProps: Record<string, unknown>) => (
19782124
<TabContainer
19792125
container={container}
1980-
tabs={[
1981-
{
1982-
id: container.id,
1983-
title: container.title,
1984-
},
1985-
]}
1986-
activeTabId={container.id}
1987-
onTabChange={() => {}}
2126+
tabs={childTabs.map(t => ({
2127+
id: t.id,
2128+
title: t.title,
2129+
}))}
2130+
activeTabId={activeTabId}
2131+
onTabChange={tabId =>
2132+
handleTabChange(container.id, tabId)
2133+
}
2134+
onAddTab={() => handleAddTab(container.id)}
2135+
onRenameTab={handleRenameTab}
2136+
onDeleteTab={handleDeleteTab}
19882137
onRename={newTitle =>
19892138
handleRenameSection(container.id, newTitle)
19902139
}
19912140
onDelete={() => handleDeleteSection(container.id)}
1992-
onAddTile={() => onAddTile(container.id)}
19932141
dragHandleProps={dragHandleProps}
19942142
>
1995-
<SectionDropZone
1996-
sectionId={container.id}
1997-
isEmpty={isEmpty}
1998-
>
1999-
{containerTiles.length > 0 && (
2000-
<ReactGridLayout
2001-
layout={containerTiles.map(
2002-
tileToLayoutItem,
2003-
)}
2004-
containerPadding={[0, 0]}
2005-
onLayoutChange={sectionLayoutChangeHandlers.get(
2006-
container.id,
2007-
)}
2008-
cols={24}
2009-
rowHeight={32}
2143+
{(currentTabId: string | undefined) => {
2144+
if (!currentTabId) return null;
2145+
const tabTiles =
2146+
tilesByContainerId.get(currentTabId) ?? [];
2147+
const tabIsEmpty = tabTiles.length === 0;
2148+
return (
2149+
<SectionDropZone
2150+
sectionId={currentTabId}
2151+
isEmpty={tabIsEmpty}
20102152
>
2011-
{containerTiles.map(renderTileComponent)}
2012-
</ReactGridLayout>
2013-
)}
2014-
</SectionDropZone>
2153+
{tabTiles.length > 0 && (
2154+
<ReactGridLayout
2155+
layout={tabTiles.map(tileToLayoutItem)}
2156+
containerPadding={[0, 0]}
2157+
onLayoutChange={sectionLayoutChangeHandlers.get(
2158+
currentTabId,
2159+
)}
2160+
cols={24}
2161+
rowHeight={32}
2162+
>
2163+
{tabTiles.map(renderTileComponent)}
2164+
</ReactGridLayout>
2165+
)}
2166+
</SectionDropZone>
2167+
);
2168+
}}
20152169
</TabContainer>
20162170
)}
20172171
</SortableSectionWrapper>
@@ -2110,7 +2264,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
21102264
>
21112265
New Section
21122266
</Menu.Item>
2113-
{/* Tab container hidden until multi-tab support is implemented */}
2267+
<Menu.Item
2268+
data-testid="add-new-tab-menu-item"
2269+
leftSection={<IconBoxMultiple size={16} />}
2270+
onClick={() => handleAddContainer('tab')}
2271+
>
2272+
New Tab Container
2273+
</Menu.Item>
21142274
<Menu.Item
21152275
data-testid="add-new-group-menu-item"
21162276
leftSection={<IconSquaresDiagonal size={16} />}

0 commit comments

Comments
 (0)