Skip to content

Commit 0d2b11f

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 TabContainer component rewritten as render-prop pattern: children receives activeTabId to render only the active tab's tiles. Dashboard page updates: - handleAddTab, handleRenameTab, handleDeleteTab, handleTabChange - handleDeleteSection now cascades to child tab containers - tabsByParentId memo for efficient child tab lookup - tilesByContainerId includes child tab containers - sections excludes child tabs from top-level rendering - "New Tab Container" restored in Add menu
1 parent 8857503 commit 0d2b11f

3 files changed

Lines changed: 347 additions & 86 deletions

File tree

packages/app/src/DBDashboardPage.tsx

Lines changed: 190 additions & 40 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,7 +1165,14 @@ 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
);
@@ -1406,12 +1414,27 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
14061414
setDashboard(
14071415
produce(dashboard, draft => {
14081416
if (!draft.containers) draft.containers = [];
1417+
const containerId = makeId();
14091418
draft.containers.push({
1410-
id: makeId(),
1419+
id: containerId,
14111420
type,
14121421
title: titles[type],
14131422
collapsed: false,
14141423
});
1424+
// Tab containers get an initial child tab
1425+
if (type === 'tab') {
1426+
const firstTabId = makeId();
1427+
draft.containers.push({
1428+
id: firstTabId,
1429+
type: 'tab',
1430+
title: 'Tab 1',
1431+
collapsed: false,
1432+
parentId: containerId,
1433+
});
1434+
// Set the initial active tab
1435+
const parent = draft.containers.find(c => c.id === containerId);
1436+
if (parent) parent.activeTabId = firstTabId;
1437+
}
14151438
}),
14161439
);
14171440
},
@@ -1463,23 +1486,31 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
14631486

14641487
setDashboard(
14651488
produce(dashboard, draft => {
1466-
const sectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
1489+
// Collect IDs to delete: the container + any child tabs
1490+
const childIds = new Set(
1491+
(draft.containers ?? [])
1492+
.filter(c => c.parentId === containerId)
1493+
.map(c => c.id),
1494+
);
1495+
const idsToDelete = new Set([containerId, ...childIds]);
1496+
1497+
const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []);
14671498
let maxUngroupedY = 0;
14681499
for (const tile of draft.tiles) {
1469-
if (!tile.containerId || !sectionIds.has(tile.containerId)) {
1500+
if (!tile.containerId || !allSectionIds.has(tile.containerId)) {
14701501
maxUngroupedY = Math.max(maxUngroupedY, tile.y + tile.h);
14711502
}
14721503
}
14731504

14741505
for (const tile of draft.tiles) {
1475-
if (tile.containerId === containerId) {
1506+
if (tile.containerId && idsToDelete.has(tile.containerId)) {
14761507
tile.y += maxUngroupedY;
14771508
delete tile.containerId;
14781509
}
14791510
}
14801511

14811512
draft.containers = draft.containers?.filter(
1482-
s => s.id !== containerId,
1513+
s => !idsToDelete.has(s.id),
14831514
);
14841515
}),
14851516
);
@@ -1501,18 +1532,106 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
15011532
[dashboard, setDashboard],
15021533
);
15031534

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

15171636
const ungroupedTiles = useMemo(
15181637
() =>
@@ -1524,6 +1643,19 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
15241643
[hasSections, allTiles, tilesByContainerId],
15251644
);
15261645

1646+
// Child tabs grouped by parent ID
1647+
const tabsByParentId = useMemo(() => {
1648+
const map = new Map<string, DashboardContainer[]>();
1649+
for (const c of allContainers) {
1650+
if (c.parentId) {
1651+
const list = map.get(c.parentId) ?? [];
1652+
list.push(c);
1653+
map.set(c.parentId, list);
1654+
}
1655+
}
1656+
return map;
1657+
}, [allContainers]);
1658+
15271659
const onUngroupedLayoutChange = useMemo(
15281660
() => makeOnLayoutChange(ungroupedTiles),
15291661
[makeOnLayoutChange, ungroupedTiles],
@@ -1968,6 +2100,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
19682100
}
19692101

19702102
if (container.type === 'tab') {
2103+
const childTabs = tabsByParentId.get(container.id) ?? [];
2104+
const activeTabId =
2105+
container.activeTabId ?? childTabs[0]?.id;
2106+
19712107
return (
19722108
<SortableSectionWrapper
19732109
key={container.id}
@@ -1977,41 +2113,49 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
19772113
{(dragHandleProps: Record<string, unknown>) => (
19782114
<TabContainer
19792115
container={container}
1980-
tabs={[
1981-
{
1982-
id: container.id,
1983-
title: container.title,
1984-
},
1985-
]}
1986-
activeTabId={container.id}
1987-
onTabChange={() => {}}
2116+
tabs={childTabs.map(t => ({
2117+
id: t.id,
2118+
title: t.title,
2119+
}))}
2120+
activeTabId={activeTabId}
2121+
onTabChange={tabId =>
2122+
handleTabChange(container.id, tabId)
2123+
}
2124+
onAddTab={() => handleAddTab(container.id)}
2125+
onRenameTab={handleRenameTab}
2126+
onDeleteTab={handleDeleteTab}
19882127
onRename={newTitle =>
19892128
handleRenameSection(container.id, newTitle)
19902129
}
19912130
onDelete={() => handleDeleteSection(container.id)}
1992-
onAddTile={() => onAddTile(container.id)}
19932131
dragHandleProps={dragHandleProps}
19942132
>
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}
2133+
{(currentTabId: string | undefined) => {
2134+
if (!currentTabId) return null;
2135+
const tabTiles =
2136+
tilesByContainerId.get(currentTabId) ?? [];
2137+
const tabIsEmpty = tabTiles.length === 0;
2138+
return (
2139+
<SectionDropZone
2140+
sectionId={currentTabId}
2141+
isEmpty={tabIsEmpty}
20102142
>
2011-
{containerTiles.map(renderTileComponent)}
2012-
</ReactGridLayout>
2013-
)}
2014-
</SectionDropZone>
2143+
{tabTiles.length > 0 && (
2144+
<ReactGridLayout
2145+
layout={tabTiles.map(tileToLayoutItem)}
2146+
containerPadding={[0, 0]}
2147+
onLayoutChange={sectionLayoutChangeHandlers.get(
2148+
currentTabId,
2149+
)}
2150+
cols={24}
2151+
rowHeight={32}
2152+
>
2153+
{tabTiles.map(renderTileComponent)}
2154+
</ReactGridLayout>
2155+
)}
2156+
</SectionDropZone>
2157+
);
2158+
}}
20152159
</TabContainer>
20162160
)}
20172161
</SortableSectionWrapper>
@@ -2110,7 +2254,13 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) {
21102254
>
21112255
New Section
21122256
</Menu.Item>
2113-
{/* Tab container hidden until multi-tab support is implemented */}
2257+
<Menu.Item
2258+
data-testid="add-new-tab-menu-item"
2259+
leftSection={<IconBoxMultiple size={16} />}
2260+
onClick={() => handleAddContainer('tab')}
2261+
>
2262+
New Tab Container
2263+
</Menu.Item>
21142264
<Menu.Item
21152265
data-testid="add-new-group-menu-item"
21162266
leftSection={<IconSquaresDiagonal size={16} />}

0 commit comments

Comments
 (0)