@@ -61,6 +61,7 @@ import { notifications } from '@mantine/notifications';
6161import {
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