diff --git a/packages/app/package.json b/packages/app/package.json index 81f067f89..b142ef3cd 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,9 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.7.0", "@dagrejs/dagre": "^1.1.5", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.9.0", "@hyperdx/browser": "^0.22.0", "@hyperdx/common-utils": "^0.16.1", diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index e464de041..d20a40185 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -28,7 +28,6 @@ import { import { AlertState, ChartConfigWithDateRange, - DashboardContainer, DashboardFilter, DisplayType, Filter, @@ -71,6 +70,7 @@ import { IconPlayerPlay, IconPlus, IconRefresh, + IconSquaresDiagonal, IconTags, IconTrash, IconUpload, @@ -79,11 +79,20 @@ import { } from '@tabler/icons-react'; import { ContactSupportText } from '@/components/ContactSupportText'; +import { + EmptyContainerPlaceholder, + SortableSectionWrapper, +} from '@/components/DashboardDndComponents'; +import { + DashboardDndProvider, + type DragHandleProps, +} from '@/components/DashboardDndContext'; import EditTimeChartForm from '@/components/DBEditTimeChartForm'; import DBNumberChart from '@/components/DBNumberChart'; import DBTableChart from '@/components/DBTableChart'; import { DBTimeChart } from '@/components/DBTimeChart'; import FullscreenPanelModal from '@/components/FullscreenPanelModal'; +import GroupContainer from '@/components/GroupContainer'; import SectionHeader from '@/components/SectionHeader'; import { TimePicker } from '@/components/TimePicker'; import { @@ -92,6 +101,12 @@ import { useCreateDashboard, useDeleteDashboard, } from '@/dashboard'; +import useDashboardContainers from '@/hooks/useDashboardContainers'; +import { + calculateNextTilePosition, + getDefaultTileSize, + makeId, +} from '@/utils/tilePositioning'; import ChartContainer from './components/charts/ChartContainer'; import { DBPieChart } from './components/DBPieChart'; @@ -103,6 +118,7 @@ import SearchWhereInput, { import { Tags } from './components/Tags'; import useDashboardFilters from './hooks/useDashboardFilters'; import { useDashboardRefresh } from './hooks/useDashboardRefresh'; +import useTileSelection from './hooks/useTileSelection'; import { useBrandDisplayName } from './theme/ThemeProvider'; import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; import { buildTableRowSearchUrl, DEFAULT_CHART_CONFIG } from './ChartUtils'; @@ -126,10 +142,14 @@ import { useZIndex, ZIndexContext } from './zIndex'; import 'react-grid-layout/css/styles.css'; import 'react-resizable/css/styles.css'; -const makeId = () => Math.floor(100000000 * Math.random()).toString(36); - const ReactGridLayout = WidthProvider(RGL); +type MoveTarget = { + containerId: string; + tabId?: string; + label: string; +}; + const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ i: chart.id, x: chart.x, @@ -157,7 +177,7 @@ const Tile = forwardRef( onDeleteClick, onUpdateChart, onMoveToSection, - containers: availableSections, + moveTargets, granularity, onTimeRangeSelect, filters, @@ -170,6 +190,8 @@ const Tile = forwardRef( onTouchEnd, children, isHighlighted, + isSelected, + onSelect, }: { chart: Tile; dateRange: [Date, Date]; @@ -178,8 +200,11 @@ const Tile = forwardRef( onAddAlertClick?: () => void; onDeleteClick: () => void; onUpdateChart?: (chart: Tile) => void; - onMoveToSection?: (containerId: string | undefined) => void; - containers?: DashboardContainer[]; + onMoveToSection?: ( + containerId: string | undefined, + tabId?: string, + ) => void; + moveTargets?: MoveTarget[]; onSettled?: () => void; granularity: SQLInterval | undefined; onTimeRangeSelect: (start: Date, end: Date) => void; @@ -193,6 +218,8 @@ const Tile = forwardRef( onTouchEnd?: (e: React.TouchEvent) => void; children?: React.ReactNode; // Resizer tooltip isHighlighted?: boolean; + isSelected?: boolean; + onSelect?: (tileId: string, shiftKey: boolean) => void; }, ref: ForwardedRef, ) => { @@ -396,40 +423,44 @@ const Tile = forwardRef( > - {onMoveToSection && - availableSections && - availableSections.length > 0 && ( - - - - - - - - Move to Section - {chart.containerId && ( - onMoveToSection(undefined)}> - (Ungrouped) + {onMoveToSection && moveTargets && moveTargets.length > 0 && ( + + + + + + + + Move to Section + {chart.containerId && ( + onMoveToSection(undefined)}> + (Ungrouped) + + )} + {moveTargets + .filter( + t => + !( + t.containerId === chart.containerId && + t.tabId === chart.tabId + ), + ) + .map(t => ( + onMoveToSection(t.containerId, t.tabId)} + > + {t.label} - )} - {availableSections - .filter(s => s.id !== chart.containerId) - .map(s => ( - onMoveToSection(s.id)} - > - {s.title} - - ))} - - - )} + ))} + + + )} { + if (e.shiftKey && onSelect) { + e.preventDefault(); + onSelect(chart.id, true); + } }} onMouseDown={onMouseDown} onMouseUp={onMouseUp} onTouchEnd={onTouchEnd} > - - - + {hovered && ( +
+ )}
e.stopPropagation()} @@ -1101,7 +1158,7 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { const [editedTile, setEditedTile] = useState(); - const onAddTile = (containerId?: string) => { + const onAddTile = (containerId?: string, tabId?: string) => { // Auto-expand collapsed section so the new tile is visible if (containerId && dashboard) { const section = dashboard.containers?.find(s => s.id === containerId); @@ -1114,17 +1171,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { ); } } + // Place tile in next available slot (fill right, then wrap) + const defaultSize = getDefaultTileSize(); + const targetTiles = (dashboard?.tiles ?? []).filter(t => { + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition( + targetTiles, + defaultSize.w, + defaultSize.h, + ); setEditedTile({ id: makeId(), - x: 0, - y: 0, - w: 8, - h: 10, + x: pos.x, + y: pos.y, + w: defaultSize.w, + h: defaultSize.h, config: { ...DEFAULT_CHART_CONFIG, source: sources?.[0]?.id ?? '', }, ...(containerId ? { containerId } : {}), + ...(tabId ? { tabId } : {}), }); }; @@ -1132,19 +1204,79 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { () => dashboard?.containers ?? [], [dashboard?.containers], ); - const hasSections = sections.length > 0; + + // Valid move targets: sections, groups, and individual tabs within groups + const moveTargetContainers = useMemo(() => { + const targets: MoveTarget[] = []; + for (const c of sections) { + const cTabs = c.tabs ?? []; + if (c.type === 'group' && cTabs.length >= 2) { + // Use container title as prefix only when tab name differs + const containerLabel = cTabs[0]?.title ?? c.title; + for (const tab of cTabs) { + const label = + tab.title === containerLabel + ? tab.title + : `${containerLabel} > ${tab.title}`; + targets.push({ + containerId: c.id, + tabId: tab.id, + label, + }); + } + } else if (c.type === 'group' && cTabs.length === 1) { + // 1-tab group: show just the group name, target the single tab + targets.push({ + containerId: c.id, + tabId: cTabs[0].id, + label: cTabs[0].title, + }); + } else { + targets.push({ containerId: c.id, label: c.title }); + } + } + return targets; + }, [sections]); + + const hasContainers = sections.length > 0; + // Keep backward-compatible alias + const hasSections = hasContainers; const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); + // --- Select-and-group workflow (Shift+click → Cmd+G) --- + const { + selectedTileIds, + setSelectedTileIds, + handleTileSelect, + handleGroupSelected, + } = useTileSelection({ dashboard, setDashboard }); + const handleMoveTileToSection = useCallback( - (tileId: string, containerId: string | undefined) => { + (tileId: string, containerId: string | undefined, tabId?: string) => { 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; - } + if (!tile) return; + + // Update container assignment + if (containerId) tile.containerId = containerId; + else delete tile.containerId; + if (tabId) tile.tabId = tabId; + else delete tile.tabId; + + // Place in next available slot in target grid + const targetTiles = draft.tiles.filter(t => { + if (t.id === tileId) return false; + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition(targetTiles, tile.w, tile.h); + tile.x = pos.x; + tile.y = pos.y; }), ); }, @@ -1235,10 +1367,12 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { }); } }} - containers={sections} - onMoveToSection={containerId => - handleMoveTileToSection(chart.id, containerId) + moveTargets={moveTargetContainers} + onMoveToSection={(containerId, tabId) => + handleMoveTileToSection(chart.id, containerId, tabId) } + isSelected={selectedTileIds.has(chart.id)} + onSelect={handleTileSelect} /> ), [ @@ -1254,8 +1388,10 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { whereLanguage, onTimeRangeSelect, filterQueries, - sections, + moveTargetContainers, handleMoveTileToSection, + selectedTileIds, + handleTileSelect, ], ); @@ -1287,89 +1423,26 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { [dashboard, setDashboard], ); - // Intentionally persists collapsed state to the server via setDashboard - // (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( - (containerId: string) => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const section = draft.containers?.find(s => s.id === containerId); - if (section) section.collapsed = !section.collapsed; - }), - ); - }, - [dashboard, setDashboard], - ); - - 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 { + handleAddContainer, + handleToggleSection, + handleRenameSection, + handleDeleteSection, + handleReorderSections, + handleAddTab, + handleRenameTab, + handleDeleteTab, + handleTabChange, + } = useDashboardContainers({ dashboard, setDashboard, confirm }); + + // Group tiles by container. + // Orphaned tiles (containerId not matching any container) become ungrouped. const tilesByContainerId = useMemo(() => { const map = new Map(); - for (const section of sections) { + for (const c of sections) { map.set( - section.id, - allTiles.filter(t => t.containerId === section.id), + c.id, + allTiles.filter(t => t.containerId === c.id), ); } return map; @@ -1722,6 +1795,32 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { onSetFilterValue={setFilterValue} dateRange={searchedTimeRange} /> + {/* Selection indicator */} + {selectedTileIds.size > 0 && ( + + + + {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} + selected + + + + + + )} {dashboard != null && dashboard.tiles != null ? ( } > - {hasSections ? ( - <> - {ungroupedTiles.length > 0 && ( - - {ungroupedTiles.map(renderTileComponent)} - - )} - {sections.map(section => { - const sectionTiles = tilesByContainerId.get(section.id) ?? []; - return ( -
- handleToggleSection(section.id)} - onRename={newTitle => - handleRenameSection(section.id, newTitle) - } - onDelete={() => handleDeleteSection(section.id)} - onAddTile={() => onAddTile(section.id)} - /> - {!section.collapsed && sectionTiles.length > 0 && ( - + {hasContainers ? ( + <> + {ungroupedTiles.length > 0 && ( + + {ungroupedTiles.map(renderTileComponent)} + + )} + {sections.map(container => { + const containerTiles = + tilesByContainerId.get(container.id) ?? []; + const isEmpty = + containerTiles.length === 0 && !container.collapsed; + + // Render based on container type + if (container.type === 'group') { + const groupTabs = container.tabs ?? []; + const groupActiveTabId = + container.activeTabId ?? groupTabs[0]?.id; + const hasTabs = groupTabs.length >= 2; + + return ( + - {sectionTiles.map(renderTileComponent)} - - )} -
- ); - })} - - ) : ( - - {ungroupedTiles.map(renderTileComponent)} - - )} + {(dragHandleProps: DragHandleProps) => ( + handleDeleteSection(container.id)} + onAddTile={() => + onAddTile( + container.id, + hasTabs ? groupActiveTabId : undefined, + ) + } + activeTabId={groupActiveTabId} + onTabChange={tabId => + handleTabChange(container.id, tabId) + } + onAddTab={() => handleAddTab(container.id)} + onRenameTab={(tabId, newTitle) => + handleRenameTab(container.id, tabId, newTitle) + } + onDeleteTab={tabId => + handleDeleteTab(container.id, tabId) + } + dragHandleProps={dragHandleProps} + > + {(currentTabId: string | undefined) => { + const visibleTiles = currentTabId + ? containerTiles.filter( + t => t.tabId === currentTabId, + ) + : containerTiles; + const visibleIsEmpty = + visibleTiles.length === 0; + return ( + + onAddTile(container.id, currentTabId) + } + > + {visibleTiles.length > 0 && ( + + {visibleTiles.map(renderTileComponent)} + + )} + + ); + }} + + )} + + ); + } + + // Default: section type + return ( + + {(dragHandleProps: DragHandleProps) => ( +
+ handleToggleSection(container.id)} + onRename={newTitle => + handleRenameSection(container.id, newTitle) + } + onDelete={() => handleDeleteSection(container.id)} + onAddTile={() => onAddTile(container.id)} + dragHandleProps={dragHandleProps} + /> + onAddTile(container.id)} + > + {!container.collapsed && + containerTiles.length > 0 && ( + + {containerTiles.map(renderTileComponent)} + + )} + +
+ )} +
+ ); + })} + + ) : ( + + {ungroupedTiles.map(renderTileComponent)} + + )} +
) : null}
@@ -1811,13 +2014,22 @@ function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { > New Tile + + Containers } - onClick={handleAddSection} + onClick={() => handleAddContainer('section')} > New Section + } + onClick={() => handleAddContainer('group')} + > + New Group + { }).success, ).toBe(false); }); + + it('validates a group container without tabs (legacy plain group)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-1', + type: 'group', + title: 'Key Metrics', + collapsed: false, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('group'); + expect(result.data.tabs).toBeUndefined(); + } + }); + + it('validates a group with 1 tab (new default for groups)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-new', + type: 'group', + title: 'New Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'New Group' }], + activeTabId: 'tab-1', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('group'); + expect(result.data.tabs).toHaveLength(1); + expect(result.data.tabs![0].title).toBe('New Group'); + expect(result.data.activeTabId).toBe('tab-1'); + } + }); + + it('validates a group with 2+ tabs (tab bar behavior)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-2', + type: 'group', + title: 'Overview Group', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + activeTabId: 'tab-a', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('group'); + expect(result.data.tabs).toHaveLength(2); + expect(result.data.activeTabId).toBe('tab-a'); + } + }); + + it('validates a group with 1 tab (plain group, no tab bar)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'group-3', + type: 'group', + title: 'Single Tab Group', + collapsed: false, + tabs: [{ id: 'tab-only', title: 'Only Tab' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.type).toBe('group'); + expect(result.data.tabs).toHaveLength(1); + } + }); + + it('rejects an invalid container type', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'c-1', + type: 'invalid', + title: 'Bad Type', + collapsed: false, + }); + expect(result.success).toBe(false); + }); + + it('rejects type tab (no longer valid)', () => { + const result = DashboardContainerSchema.safeParse({ + id: 'c-2', + type: 'tab', + title: 'Old Tab', + collapsed: false, + }); + expect(result.success).toBe(false); + }); }); -describe('Tile schema with containerId', () => { +describe('Tile schema with containerId and tabId', () => { const baseTile = { id: 'tile-1', x: 0, @@ -79,6 +166,7 @@ describe('Tile schema with containerId', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.containerId).toBeUndefined(); + expect(result.data.tabId).toBeUndefined(); } }); @@ -92,6 +180,30 @@ describe('Tile schema with containerId', () => { expect(result.data.containerId).toBe('section-1'); } }); + + it('validates a tile with containerId and tabId', () => { + const result = TileSchema.safeParse({ + ...baseTile, + containerId: 'group-1', + tabId: 'tab-a', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.containerId).toBe('group-1'); + expect(result.data.tabId).toBe('tab-a'); + } + }); + + it('validates a tile with tabId but no containerId', () => { + const result = TileSchema.safeParse({ + ...baseTile, + tabId: 'orphan-tab', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tabId).toBe('orphan-tab'); + } + }); }); describe('Dashboard schema with sections', () => { @@ -193,11 +305,58 @@ describe('Dashboard schema with sections', () => { expect(result.data.containers![0].title).toBe('Infrastructure'); } }); + + it('validates a dashboard with group container with tabs and tiles using tabId', () => { + const result = DashboardSchema.safeParse({ + ...baseDashboard, + tiles: [ + { + id: 'tile-1', + x: 0, + y: 0, + w: 8, + h: 10, + containerId: 'g1', + tabId: 'tab-a', + config: { + source: 'source-1', + select: [ + { + aggFn: 'count', + aggCondition: '', + valueExpression: '', + }, + ], + where: '', + from: { databaseName: 'default', tableName: 'logs' }, + }, + }, + ], + containers: [ + { + id: 'g1', + type: 'group', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + activeTabId: 'tab-a', + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.tiles[0].tabId).toBe('tab-a'); + expect(result.data.containers![0].tabs).toHaveLength(2); + } + }); }); describe('section tile grouping logic', () => { // Test the grouping logic used in DBDashboardPage - type SimpleTile = { id: string; containerId?: string }; + type SimpleTile = { id: string; containerId?: string; tabId?: string }; type SimpleSection = { id: string; title: string; collapsed: boolean }; function groupTilesBySection(tiles: SimpleTile[], sections: SimpleSection[]) { @@ -289,10 +448,78 @@ describe('section tile grouping logic', () => { expect(ungrouped.map(t => t.id)).toEqual(['b', 'c']); expect(bySectionId.get('s1')).toHaveLength(1); }); + + it('filters group tiles by tabId when group has tabs', () => { + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 'g1', tabId: 'tab-1' }, + ]; + const sections: SimpleSection[] = [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + ]; + const { bySectionId } = groupTilesBySection(tiles, sections); + const allGroupTiles = bySectionId.get('g1') ?? []; + expect(allGroupTiles).toHaveLength(3); + // Filter by tabId (as done in DBDashboardPage) + const tab1Tiles = allGroupTiles.filter(t => t.tabId === 'tab-1'); + const tab2Tiles = allGroupTiles.filter(t => t.tabId === 'tab-2'); + expect(tab1Tiles).toHaveLength(2); + expect(tab2Tiles).toHaveLength(1); + }); + + it('group with 0-1 tabs is plain group (no tab filtering)', () => { + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1' }, + { id: 'b', containerId: 'g1' }, + ]; + const sections: SimpleSection[] = [ + { id: 'g1', title: 'Plain Group', collapsed: false }, + ]; + const { bySectionId } = groupTilesBySection(tiles, sections); + const groupTiles = bySectionId.get('g1') ?? []; + // No tab filtering needed for plain groups + expect(groupTiles).toHaveLength(2); + expect(groupTiles.every(t => t.tabId === undefined)).toBe(true); + }); + + it('group with 2+ tabs has tab bar behavior (tiles split by tabId)', () => { + // Simulates the schema: group with tabs array of 2+ entries + type SimpleGroup = SimpleSection & { + tabs?: { id: string; title: string }[]; + activeTabId?: string; + }; + + const group: SimpleGroup = { + id: 'g1', + title: 'Tabbed Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Tab 1' }, + { id: 'tab-2', title: 'Tab 2' }, + ], + activeTabId: 'tab-1', + }; + + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 'g1', tabId: 'tab-1' }, + ]; + + const hasTabs = (group.tabs?.length ?? 0) >= 2; + expect(hasTabs).toBe(true); + + // When tabs exist, render prop receives activeTabId and filters tiles + const activeTabId = group.activeTabId ?? group.tabs![0].id; + const visibleTiles = tiles.filter(t => t.tabId === activeTabId); + expect(visibleTiles).toHaveLength(2); + expect(visibleTiles.map(t => t.id)).toEqual(['a', 'c']); + }); }); describe('section authoring operations', () => { - type SimpleTile = { id: string; containerId?: string }; + type SimpleTile = { id: string; containerId?: string; tabId?: string }; type SimpleSection = { id: string; title: string; collapsed: boolean }; type SimpleDashboard = { tiles: SimpleTile[]; @@ -324,7 +551,9 @@ describe('section authoring operations', () => { ...dashboard, containers: dashboard.containers?.filter(s => s.id !== containerId), tiles: dashboard.tiles.map(t => - t.containerId === containerId ? { ...t, containerId: undefined } : t, + t.containerId === containerId + ? { ...t, containerId: undefined, tabId: undefined } + : t, ), }; } @@ -345,11 +574,12 @@ describe('section authoring operations', () => { dashboard: SimpleDashboard, tileId: string, containerId: string | undefined, + tabId?: string, ) { return { ...dashboard, tiles: dashboard.tiles.map(t => - t.id === tileId ? { ...t, containerId } : t, + t.id === tileId ? { ...t, containerId, tabId } : t, ), }; } @@ -462,6 +692,25 @@ describe('section authoring operations', () => { expect(result.tiles.find(t => t.id === 'd')?.containerId).toBeUndefined(); }); + it('clears tabId when deleting a group with tabs', () => { + const dashboard: SimpleDashboard = { + tiles: [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + { id: 'c', containerId: 's1' }, + ], + containers: [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + { id: 's1', title: 'Section', collapsed: false }, + ], + }; + const result = deleteSection(dashboard, 'g1'); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'a')?.tabId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'b')?.tabId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('s1'); + }); + it('handles deleting the last section', () => { const dashboard: SimpleDashboard = { tiles: [{ id: 'a', containerId: 's1' }], @@ -524,5 +773,247 @@ describe('section authoring operations', () => { const result = moveTileToSection(dashboard, 'a', undefined); expect(result.tiles[0].containerId).toBeUndefined(); }); + + it('moves a tile to a specific tab in a group', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a' }], + containers: [{ id: 'g1', title: 'Group with Tabs', collapsed: false }], + }; + const result = moveTileToSection(dashboard, 'a', 'g1', 'tab-1'); + expect(result.tiles[0].containerId).toBe('g1'); + expect(result.tiles[0].tabId).toBe('tab-1'); + }); + + it('clears tabId when moving from group tab to regular section', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a', containerId: 'g1', tabId: 'tab-1' }], + containers: [ + { id: 'g1', title: 'Group with Tabs', collapsed: false }, + { id: 's1', title: 'Section', collapsed: false }, + ], + }; + const result = moveTileToSection(dashboard, 'a', 's1'); + expect(result.tiles[0].containerId).toBe('s1'); + expect(result.tiles[0].tabId).toBeUndefined(); + }); + }); + + describe('reorder sections', () => { + function reorderSections( + dashboard: SimpleDashboard, + fromIndex: number, + toIndex: number, + ) { + if (!dashboard.containers) return dashboard; + const containers = [...dashboard.containers]; + const [removed] = containers.splice(fromIndex, 1); + containers.splice(toIndex, 0, removed); + return { ...dashboard, containers }; + } + + it('moves a section from first to last', () => { + const dashboard: SimpleDashboard = { + tiles: [], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + { id: 's3', title: 'Third', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 0, 2); + expect(result.containers!.map(c => c.id)).toEqual(['s2', 's3', 's1']); + }); + + it('moves a section from last to first', () => { + const dashboard: SimpleDashboard = { + tiles: [], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + { id: 's3', title: 'Third', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 2, 0); + expect(result.containers!.map(c => c.id)).toEqual(['s3', 's1', 's2']); + }); + + it('does not affect tiles when sections are reordered', () => { + const dashboard: SimpleDashboard = { + tiles: [ + { id: 'a', containerId: 's1' }, + { id: 'b', containerId: 's2' }, + ], + containers: [ + { id: 's1', title: 'First', collapsed: false }, + { id: 's2', title: 'Second', collapsed: false }, + ], + }; + const result = reorderSections(dashboard, 0, 1); + expect(result.tiles).toEqual(dashboard.tiles); + expect(result.containers!.map(c => c.id)).toEqual(['s2', 's1']); + }); + }); + + describe('group selected tiles', () => { + function groupTilesIntoSection( + dashboard: SimpleDashboard, + tileIds: string[], + newSection: SimpleSection, + ) { + const containers = [...(dashboard.containers ?? []), newSection]; + const tiles = dashboard.tiles.map(t => + tileIds.includes(t.id) ? { ...t, containerId: newSection.id } : t, + ); + return { ...dashboard, containers, tiles }; + } + + it('groups selected tiles into a new section', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + }; + const result = groupTilesIntoSection(dashboard, ['a', 'c'], { + id: 'new-s', + title: 'New Section', + collapsed: false, + }); + expect(result.containers).toHaveLength(1); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBe('new-s'); + expect(result.tiles.find(t => t.id === 'b')?.containerId).toBeUndefined(); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('new-s'); + }); + + it('preserves existing sections when grouping', () => { + const dashboard: SimpleDashboard = { + tiles: [{ id: 'a', containerId: 's1' }, { id: 'b' }, { id: 'c' }], + containers: [{ id: 's1', title: 'Existing', collapsed: false }], + }; + const result = groupTilesIntoSection(dashboard, ['b', 'c'], { + id: 'new-s', + title: 'Grouped', + collapsed: false, + }); + expect(result.containers).toHaveLength(2); + expect(result.tiles.find(t => t.id === 'a')?.containerId).toBe('s1'); + expect(result.tiles.find(t => t.id === 'b')?.containerId).toBe('new-s'); + expect(result.tiles.find(t => t.id === 'c')?.containerId).toBe('new-s'); + }); + }); +}); + +describe('group tab operations', () => { + type SimpleTab = { id: string; title: string }; + type SimpleGroup = { + id: string; + title: string; + type: 'group'; + collapsed: boolean; + tabs?: SimpleTab[]; + activeTabId?: string; + }; + type SimpleTile = { id: string; containerId?: string; tabId?: string }; + + it('group creation always has 1 tab', () => { + // Simulates handleAddContainer('group') + const tabId = 'tab-new'; + const group: SimpleGroup = { + id: 'g1', + type: 'group', + title: 'New Group', + collapsed: false, + tabs: [{ id: tabId, title: 'New Group' }], + activeTabId: tabId, + }; + + expect(group.tabs).toHaveLength(1); + expect(group.tabs![0].title).toBe('New Group'); + expect(group.activeTabId).toBe(tabId); + }); + + it('adding tab to 1-tab group creates second tab without renaming first', () => { + // Simulates handleAddTab for a group with 1 tab + const group: SimpleGroup = { + id: 'g1', + type: 'group', + title: 'My Group', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'My Group' }], + activeTabId: 'tab-1', + }; + const tiles: SimpleTile[] = [{ id: 'a', containerId: 'g1' }]; + + // Add second tab (simulates the hook logic) + const newTabId = 'tab-2'; + const updatedTabs = [...group.tabs!, { id: newTabId, title: 'New Tab' }]; + const updatedTiles = tiles.map(t => + t.containerId === 'g1' && !t.tabId ? { ...t, tabId: 'tab-1' } : t, + ); + + expect(updatedTabs).toHaveLength(2); + expect(updatedTabs[0].title).toBe('My Group'); // First tab NOT renamed + expect(updatedTabs[1].title).toBe('New Tab'); + expect(updatedTiles[0].tabId).toBe('tab-1'); + }); + + it('group title syncs from tabs[0].title for 1-tab groups', () => { + // Simulates handleRenameSection for a group with 1 tab + const group: SimpleGroup = { + id: 'g1', + type: 'group', + title: 'Old Name', + collapsed: false, + tabs: [{ id: 'tab-1', title: 'Old Name' }], + activeTabId: 'tab-1', + }; + + // Rename via header (which syncs to tabs[0]) + const newTitle = 'New Name'; + const updatedGroup = { + ...group, + title: newTitle, + tabs: group.tabs!.map((t, i) => + i === 0 ? { ...t, title: newTitle } : t, + ), + }; + + expect(updatedGroup.title).toBe('New Name'); + expect(updatedGroup.tabs![0].title).toBe('New Name'); + }); + + it('removing to 1 tab keeps the tab in the array', () => { + // Simulates handleDeleteTab leaving 1 tab + const group: SimpleGroup = { + id: 'g1', + type: 'group', + title: 'My Group', + collapsed: false, + tabs: [ + { id: 'tab-1', title: 'Tab A' }, + { id: 'tab-2', title: 'Tab B' }, + ], + activeTabId: 'tab-1', + }; + const tiles: SimpleTile[] = [ + { id: 'a', containerId: 'g1', tabId: 'tab-1' }, + { id: 'b', containerId: 'g1', tabId: 'tab-2' }, + ]; + + // Delete tab-2, keep tab-1 + const deletedTabId = 'tab-2'; + const remaining = group.tabs!.filter(t => t.id !== deletedTabId); + const keepTab = remaining[0]; + + // Move tiles from deleted tab to remaining tab + const updatedTiles = tiles.map(t => + t.containerId === 'g1' && t.tabId === deletedTabId + ? { ...t, tabId: keepTab.id } + : t, + ); + + expect(remaining).toHaveLength(1); + expect(remaining[0].id).toBe('tab-1'); + // All tiles should now reference the remaining tab + expect(updatedTiles.every(t => t.tabId === 'tab-1')).toBe(true); + // Tab bar hidden because only 1 tab remains (rendering handles this) + expect(remaining.length >= 2).toBe(false); }); }); diff --git a/packages/app/src/components/DashboardDndComponents.tsx b/packages/app/src/components/DashboardDndComponents.tsx new file mode 100644 index 000000000..f1b663a41 --- /dev/null +++ b/packages/app/src/components/DashboardDndComponents.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Box, Button } from '@mantine/core'; +import { IconPlus } from '@tabler/icons-react'; + +import { type DragData, type DragHandleProps } from './DashboardDndContext'; + +// --- Empty container placeholder --- +// Visual placeholder for empty sections/groups/tabs with optional add-tile click. + +export function EmptyContainerPlaceholder({ + sectionId, + children, + isEmpty, + onAddTile, +}: { + sectionId: string; + children?: React.ReactNode; + isEmpty?: boolean; + onAddTile?: () => void; +}) { + return ( +
+ {isEmpty && ( + + + + )} + {children} +
+ ); +} + +// --- Sortable section wrapper (for container reordering) --- + +export function SortableSectionWrapper({ + sectionId, + sectionTitle, + children, +}: { + sectionId: string; + sectionTitle: string; + children: (dragHandleProps: DragHandleProps) => React.ReactNode; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: `section-sort-${sectionId}`, + data: { + type: 'section', + sectionId, + sectionTitle, + } satisfies DragData, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ {children({ ...attributes, ...listeners })} +
+ ); +} diff --git a/packages/app/src/components/DashboardDndContext.tsx b/packages/app/src/components/DashboardDndContext.tsx new file mode 100644 index 000000000..bd6113326 --- /dev/null +++ b/packages/app/src/components/DashboardDndContext.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + MouseSensor, + TouchSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { Box, Text } from '@mantine/core'; + +// --- Types --- + +export type DragHandleProps = React.HTMLAttributes; + +export type DragData = { + type: 'section'; + sectionId: string; + sectionTitle: string; +}; + +type Props = { + children: React.ReactNode; + containers: DashboardContainer[]; + onReorderSections: (fromIndex: number, toIndex: number) => void; +}; + +// --- Provider (section reorder only) --- + +export function DashboardDndProvider({ + children, + containers, + onReorderSections, +}: Props) { + const [activeDrag, setActiveDrag] = useState(null); + + const mouseSensor = useSensor(MouseSensor, { + activationConstraint: { distance: 8 }, + }); + const touchSensor = useSensor(TouchSensor, { + activationConstraint: { delay: 200, tolerance: 5 }, + }); + const sensors = useSensors(mouseSensor, touchSensor); + + const sectionSortableIds = useMemo( + () => containers.map(c => `section-sort-${c.id}`), + [containers], + ); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveDrag((event.active.data.current as DragData) ?? null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveDrag(null); + if (!over) return; + + const activeData = active.data.current as DragData | undefined; + if (!activeData) return; + + // Section reorder via sortable + const overData = over.data.current as DragData | undefined; + if ( + overData?.type === 'section' && + activeData.sectionId !== overData.sectionId + ) { + const from = containers.findIndex(c => c.id === activeData.sectionId); + const to = containers.findIndex(c => c.id === overData.sectionId); + if (from !== -1 && to !== -1) onReorderSections(from, to); + } + }, + [containers, onReorderSections], + ); + + return ( + + + {children} + + + {activeDrag && ( + + + {activeDrag.sectionTitle} + + + )} + + + ); +} diff --git a/packages/app/src/components/GroupContainer.tsx b/packages/app/src/components/GroupContainer.tsx new file mode 100644 index 000000000..d1acf3871 --- /dev/null +++ b/packages/app/src/components/GroupContainer.tsx @@ -0,0 +1,302 @@ +import { useState } from 'react'; +import { DashboardContainer } from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + CloseButton, + Flex, + Input, + Menu, + Tabs, + Text, +} from '@mantine/core'; +import { + IconDotsVertical, + IconGripVertical, + IconPlus, + IconTrash, +} from '@tabler/icons-react'; + +import { type DragHandleProps } from '@/components/DashboardDndContext'; + +type GroupContainerProps = { + container: DashboardContainer; + onDelete?: () => void; + onAddTile?: () => void; + activeTabId?: string; + onTabChange?: (tabId: string) => void; + onAddTab?: () => void; + onRenameTab?: (tabId: string, newTitle: string) => void; + onDeleteTab?: (tabId: string) => void; + children: (activeTabId: string | undefined) => React.ReactNode; + dragHandleProps?: DragHandleProps; +}; + +export default function GroupContainer({ + container, + onDelete, + onAddTile, + activeTabId, + onTabChange, + onAddTab, + onRenameTab, + onDeleteTab, + children, + dragHandleProps, +}: GroupContainerProps) { + const [editing, setEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(container.title); + const [hovered, setHovered] = useState(false); + const [editingTabId, setEditingTabId] = useState(null); + const [editedTabTitle, setEditedTabTitle] = useState(''); + const [hoveredTabId, setHoveredTabId] = useState(null); + + const tabs = container.tabs ?? []; + const hasTabs = tabs.length >= 2; + const showControls = hovered; + const resolvedActiveTabId = activeTabId ?? tabs[0]?.id; + + const firstTab = tabs[0]; + const headerTitle = firstTab?.title ?? container.title; + + const handleSaveRename = () => { + const trimmed = editedTitle.trim(); + if (trimmed && firstTab && trimmed !== firstTab.title) { + onRenameTab?.(firstTab.id, trimmed); + } else { + setEditedTitle(headerTitle); + } + setEditing(false); + }; + + const handleSaveTabRename = (tabId: string) => { + const trimmed = editedTabTitle.trim(); + const tab = tabs.find(t => t.id === tabId); + if (trimmed && tab && trimmed !== tab.title) { + onRenameTab?.(tabId, trimmed); + } + setEditingTabId(null); + }; + + const addMenu = ( + + + + + + + + {onAddTile && Add Tile} + {onAddTab && Add Tab} + + + ); + + const deleteMenu = onDelete && ( + + + + + + + + } + color="red" + onClick={onDelete} + > + Delete Group + + + + ); + + const dragHandle = dragHandleProps && ( +
+ +
+ ); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + border: '1px solid var(--mantine-color-default-border)', + borderRadius: 4, + marginTop: 8, + }} + > + {hasTabs ? ( + /* Tab bar header (2+ tabs) — no separate title */ + val && onTabChange?.(val)} + > + + {dragHandle} + + {tabs.map(tab => ( + setHoveredTabId(tab.id)} + onMouseLeave={() => setHoveredTabId(null)} + rightSection={ + onDeleteTab && tabs.length > 1 ? ( + { + e.stopPropagation(); + onDeleteTab(tab.id); + }} + title="Remove tab" + data-testid={`tab-close-${tab.id}`} + /> + ) : undefined + } + onDoubleClick={ + onRenameTab + ? () => { + setEditingTabId(tab.id); + setEditedTabTitle(tab.title); + } + : undefined + } + > + {editingTabId === tab.id ? ( +
{ + e.preventDefault(); + handleSaveTabRename(tab.id); + }} + onClick={e => e.stopPropagation()} + > + setEditedTabTitle(e.currentTarget.value)} + onBlur={() => handleSaveTabRename(tab.id)} + onKeyDown={e => { + if (e.key === 'Escape') setEditingTabId(null); + }} + autoFocus + styles={{ input: { minWidth: 60, height: 22 } }} + data-testid={`tab-rename-input-${tab.id}`} + /> +
+ ) : ( + tab.title + )} +
+ ))} +
+ {addMenu} + {deleteMenu} +
+
+ ) : ( + /* Plain group header (1 tab) — uses tabs[0].title */ + + {dragHandle} + {editing ? ( +
{ + e.preventDefault(); + handleSaveRename(); + }} + style={{ flex: 1 }} + > + setEditedTitle(e.currentTarget.value)} + onBlur={handleSaveRename} + onKeyDown={e => { + if (e.key === 'Escape') { + setEditedTitle(headerTitle); + setEditing(false); + } + }} + autoFocus + data-testid={`group-rename-input-${container.id}`} + /> +
+ ) : ( + { + e.stopPropagation(); + setEditedTitle(headerTitle); + setEditing(true); + } + : undefined + } + > + {headerTitle} + + )} + {addMenu} + {deleteMenu} +
+ )} +
+ {children(hasTabs ? resolvedActiveTabId : undefined)} +
+
+ ); +} diff --git a/packages/app/src/components/SectionHeader.tsx b/packages/app/src/components/SectionHeader.tsx index 092acf546..50513268f 100644 --- a/packages/app/src/components/SectionHeader.tsx +++ b/packages/app/src/components/SectionHeader.tsx @@ -6,10 +6,13 @@ import { IconDotsVertical, IconEye, IconEyeOff, + IconGripVertical, IconPlus, IconTrash, } from '@tabler/icons-react'; +import { type DragHandleProps } from '@/components/DashboardDndContext'; + export default function SectionHeader({ section, tileCount, @@ -17,6 +20,7 @@ export default function SectionHeader({ onRename, onDelete, onAddTile, + dragHandleProps, }: { section: DashboardContainer; tileCount: number; @@ -24,6 +28,7 @@ export default function SectionHeader({ onRename?: (newTitle: string) => void; onDelete?: () => void; onAddTile?: () => void; + dragHandleProps?: DragHandleProps; }) { const [editing, setEditing] = useState(false); const [editedTitle, setEditedTitle] = useState(section.title); @@ -59,11 +64,32 @@ export default function SectionHeader({ onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ - borderBottom: '1px solid var(--mantine-color-dark-4)', + borderBottom: '1px solid var(--mantine-color-default-border)', userSelect: 'none', }} data-testid={`section-header-${section.id}`} > + {dragHandleProps && ( +
+ +
+ )} Promise; + +export default function useDashboardContainers({ + dashboard, + setDashboard, + confirm, +}: { + dashboard: Dashboard | undefined; + setDashboard: (dashboard: Dashboard) => void; + confirm: ConfirmFn; +}) { + const handleAddContainer = useCallback( + (type: 'section' | 'group' = 'section') => { + if (!dashboard) return; + const titles: Record = { + section: 'New Section', + group: 'New Group', + }; + setDashboard( + produce(dashboard, draft => { + if (!draft.containers) draft.containers = []; + const containerId = makeId(); + if (type === 'group') { + const tabId = makeId(); + draft.containers.push({ + id: containerId, + type, + title: titles[type], + collapsed: false, + tabs: [{ id: tabId, title: titles[type] }], + activeTabId: tabId, + }); + } else { + draft.containers.push({ + id: containerId, + type, + title: titles[type], + collapsed: false, + }); + } + }), + ); + }, + [dashboard, setDashboard], + ); + + // Intentionally persists collapsed state to the server via setDashboard + // (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( + (containerId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const section = draft.containers?.find(s => s.id === containerId); + if (section) section.collapsed = !section.collapsed; + }), + ); + }, + [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(); + // For groups with 1 tab, sync tabs[0].title (they share the header) + if (section.type === 'group' && section.tabs?.length === 1) { + section.tabs[0].title = newTitle.trim(); + } + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleDeleteSection = useCallback( + async (containerId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + const tileCount = dashboard.tiles.filter( + t => t.containerId === containerId, + ).length; + const label = container?.title ?? 'this section'; + + const message = + tileCount > 0 ? ( + <> + Delete{' '} + + {label} + + ?{' '} + {`${tileCount} tile${tileCount > 1 ? 's' : ''} will become ungrouped.`} + + ) : ( + <> + Delete{' '} + + {label} + + ? + + ); + + const confirmed = await confirm(message, 'Delete', { + variant: 'danger', + }); + if (!confirmed) return; + + setDashboard( + produce(dashboard, draft => { + const allSectionIds = new Set(draft.containers?.map(c => c.id) ?? []); + let maxUngroupedY = 0; + for (const tile of draft.tiles) { + if (!tile.containerId || !allSectionIds.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; + delete tile.tabId; + } + } + + draft.containers = draft.containers?.filter( + s => s.id !== containerId, + ); + }), + ); + }, + [dashboard, setDashboard, confirm], + ); + + const handleReorderSections = useCallback( + (fromIndex: number, toIndex: number) => { + if (!dashboard?.containers) return; + setDashboard( + produce(dashboard, draft => { + if (draft.containers) { + draft.containers = arrayMove(draft.containers, fromIndex, toIndex); + } + }), + ); + }, + [dashboard, setDashboard], + ); + + // --- Tab management --- + + const handleAddTab = useCallback( + (containerId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + if (!container) return; + const existingTabs = container.tabs ?? []; + + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(c => c.id === containerId); + if (!c) return; + + if (existingTabs.length === 1) { + // Group already has 1 tab (the default); just add a second tab + const newTabId = makeId(); + if (!c.tabs) c.tabs = []; + c.tabs.push({ id: newTabId, title: 'New Tab' }); + c.activeTabId = newTabId; + // Ensure existing tiles are assigned to the first tab + const firstTabId = existingTabs[0].id; + for (const tile of draft.tiles) { + if (tile.containerId === containerId && !tile.tabId) { + tile.tabId = firstTabId; + } + } + } else if (existingTabs.length === 0) { + // Legacy group with no tabs: create 2 tabs + const tab1Id = makeId(); + const tab2Id = makeId(); + c.tabs = [ + { id: tab1Id, title: 'Tab 1' }, + { id: tab2Id, title: 'Tab 2' }, + ]; + c.activeTabId = tab1Id; + for (const tile of draft.tiles) { + if (tile.containerId === containerId) { + tile.tabId = tab1Id; + } + } + } else { + // Already has 2+ tabs, add one more + if (!c.tabs) c.tabs = []; + const newTabId = makeId(); + c.tabs.push({ + id: newTabId, + title: `Tab ${existingTabs.length + 1}`, + }); + c.activeTabId = newTabId; + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleRenameTab = useCallback( + (containerId: string, tabId: string, newTitle: string) => { + if (!dashboard || !newTitle.trim()) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(c => c.id === containerId); + const tab = container?.tabs?.find(t => t.id === tabId); + if (tab) tab.title = newTitle.trim(); + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleDeleteTab = useCallback( + (containerId: string, tabId: string) => { + if (!dashboard) return; + const container = dashboard.containers?.find(c => c.id === containerId); + if (!container?.tabs) return; + const remaining = container.tabs.filter(t => t.id !== tabId); + + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(c => c.id === containerId); + if (!c?.tabs) return; + + if (remaining.length <= 1) { + // Keep the 1 remaining tab (don't clear tabs array) + const keepTab = remaining[0]; + c.tabs = remaining; + c.activeTabId = keepTab?.id; + // Move tiles from deleted tab to the remaining tab + for (const tile of draft.tiles) { + if (tile.containerId === containerId && tile.tabId === tabId) { + tile.tabId = keepTab?.id; + } + } + } else { + const targetTabId = remaining[0].id; + // Move tiles from deleted tab to first remaining tab + for (const tile of draft.tiles) { + if (tile.containerId === containerId && tile.tabId === tabId) { + tile.tabId = targetTabId; + } + } + c.tabs = c.tabs.filter(t => t.id !== tabId); + if (c.activeTabId === tabId) { + c.activeTabId = targetTabId; + } + } + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleTabChange = useCallback( + (containerId: string, tabId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const container = draft.containers?.find(c => c.id === containerId); + if (container) container.activeTabId = tabId; + }), + ); + }, + [dashboard, setDashboard], + ); + + return { + handleAddContainer, + handleToggleSection, + handleRenameSection, + handleDeleteSection, + handleReorderSections, + handleAddTab, + handleRenameTab, + handleDeleteTab, + handleTabChange, + }; +} diff --git a/packages/app/src/hooks/useTileSelection.ts b/packages/app/src/hooks/useTileSelection.ts new file mode 100644 index 000000000..e0b345f37 --- /dev/null +++ b/packages/app/src/hooks/useTileSelection.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from 'react'; +import produce from 'immer'; +import { useHotkeys } from '@mantine/hooks'; + +import { Dashboard } from '@/dashboard'; +import { makeId } from '@/utils/tilePositioning'; + +export default function useTileSelection({ + dashboard, + setDashboard, +}: { + dashboard: Dashboard | undefined; + setDashboard: (dashboard: Dashboard) => void; +}) { + const [selectedTileIds, setSelectedTileIds] = useState>( + new Set(), + ); + + const handleTileSelect = useCallback((tileId: string, shiftKey: boolean) => { + if (!shiftKey) return; + setSelectedTileIds(prev => { + const next = new Set(prev); + if (next.has(tileId)) next.delete(tileId); + else next.add(tileId); + return next; + }); + }, []); + + // Creates a 'section' type container (not 'group') intentionally. + // Sections are collapsible and are the most common container type for + // organizing tiles on a dashboard. The function name reflects the user + // action (grouping selected tiles) rather than the container type created. + const handleGroupSelected = useCallback(() => { + if (!dashboard || selectedTileIds.size === 0) return; + const groupId = makeId(); + setDashboard( + produce(dashboard, draft => { + if (!draft.containers) draft.containers = []; + draft.containers.push({ + id: groupId, + type: 'section', + title: 'New Section', + collapsed: false, + }); + for (const tile of draft.tiles) { + if (selectedTileIds.has(tile.id)) { + tile.containerId = groupId; + } + } + }), + ); + setSelectedTileIds(new Set()); + }, [dashboard, selectedTileIds, setDashboard]); + + // Cmd+G / Ctrl+G to group selected tiles + useHotkeys([ + [ + 'mod+g', + e => { + e.preventDefault(); + handleGroupSelected(); + }, + ], + // Escape to clear selection + ['escape', () => setSelectedTileIds(new Set())], + ]); + + return { + selectedTileIds, + setSelectedTileIds, + handleTileSelect, + handleGroupSelected, + }; +} diff --git a/packages/app/src/utils/tilePositioning.ts b/packages/app/src/utils/tilePositioning.ts index f57d4b66d..1af54443f 100644 --- a/packages/app/src/utils/tilePositioning.ts +++ b/packages/app/src/utils/tilePositioning.ts @@ -2,6 +2,8 @@ import { DisplayType } from '@hyperdx/common-utils/dist/types'; import { Tile } from '@/dashboard'; +const GRID_COLS = 24; + /** * Generate a unique ID for a tile * @returns A random string ID in base 36 @@ -9,63 +11,76 @@ import { Tile } from '@/dashboard'; export const makeId = () => Math.floor(100000000 * Math.random()).toString(36); /** - * Calculate the next available position for a new tile at the bottom of the dashboard - * @param tiles - Array of existing tiles on the dashboard - * @returns Position object with x and y coordinates + * Calculate the next available position for a new tile, filling right + * then wrapping to the next row (like text in a book). + * + * Scans each row from top to bottom. For each row, checks if there's + * enough horizontal space to fit the new tile. If so, returns that + * position. If no row has space, places at the bottom-left. */ -export function calculateNextTilePosition(tiles: Tile[]): { - x: number; - y: number; -} { +export function calculateNextTilePosition( + tiles: Tile[], + newW: number = 12, + newH: number = 10, +): { x: number; y: number } { if (tiles.length === 0) { return { x: 0, y: 0 }; } - // Find the maximum bottom position (y + height) across all tiles - const maxBottom = Math.max(...tiles.map(tile => tile.y + tile.h)); + // Build a set of occupied rows and find the max bottom + const rows = new Set(); + let maxBottom = 0; + for (const tile of tiles) { + rows.add(tile.y); + maxBottom = Math.max(maxBottom, tile.y + tile.h); + } + + // Check each existing row for horizontal space + const sortedRows = Array.from(rows).sort((a, b) => a - b); + for (const rowY of sortedRows) { + // Find tiles on this row + const rowTiles = tiles.filter(t => t.y <= rowY && t.y + t.h > rowY); + // Calculate rightmost occupied x on this row + let rightEdge = 0; + for (const t of rowTiles) { + rightEdge = Math.max(rightEdge, t.x + t.w); + } + // Check if new tile fits to the right + if (rightEdge + newW <= GRID_COLS) { + return { x: rightEdge, y: rowY }; + } + } - return { - x: 0, // Always start at left edge - y: maxBottom, // Place at bottom of dashboard - }; + // No row has space — place at bottom-left + return { x: 0, y: maxBottom }; } /** * Get default tile dimensions based on chart display type - * @param displayType - The type of chart visualization - * @returns Dimensions object with width (w) and height (h) in grid units */ export function getDefaultTileSize(displayType?: DisplayType): { w: number; h: number; } { - const GRID_COLS = 24; // Full width of dashboard grid - switch (displayType) { case DisplayType.Line: case DisplayType.StackedBar: - // Half-width time series charts return { w: 12, h: 10 }; case DisplayType.Table: case DisplayType.Search: - // Full-width data views return { w: GRID_COLS, h: 12 }; case DisplayType.Number: - // Small metric cards return { w: 6, h: 6 }; case DisplayType.Markdown: - // Medium-sized documentation blocks return { w: 12, h: 8 }; case DisplayType.Heatmap: - // Half-width heatmap return { w: 12, h: 10 }; default: - // Default to half-width time series size return { w: 12, h: 10 }; } } diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 42d568d3c..7f8e8eabb 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -688,6 +688,8 @@ export const TileSchema = z.object({ h: z.number(), config: SavedChartConfigSchema, containerId: z.string().optional(), + // For tiles inside a tab container: which tab this tile belongs to + tabId: z.string().optional(), }); export const TileTemplateSchema = TileSchema.extend({ @@ -699,11 +701,24 @@ export const TileTemplateSchema = TileSchema.extend({ export type Tile = z.infer; +export const DashboardContainerTabSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), +}); + export const DashboardContainerSchema = z.object({ id: z.string().min(1), - type: z.enum(['section']), + // 'section': collapsible row with chevron toggle + // 'group': bordered container, optionally with tabs (2+ tabs → tab bar shows) + type: z.enum(['section', 'group']), title: z.string().min(1), collapsed: z.boolean(), + // For groups with tabs: the list of tabs and which is active. + // Tiles reference a specific tab via tabId. When tabs has 2+ entries, + // the tab bar renders. When 0-1 entries, it's a plain group. + // Persisted to server like collapsed state (Grafana/Kibana pattern). + tabs: z.array(DashboardContainerTabSchema).optional(), + activeTabId: z.string().optional(), }); export type DashboardContainer = z.infer; diff --git a/yarn.lock b/yarn.lock index 372f7119f..df79aca55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3383,6 +3383,55 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@dotenvx/dotenvx@npm:^1.51.1": version: 1.51.1 resolution: "@dotenvx/dotenvx@npm:1.51.1" @@ -4345,6 +4394,9 @@ __metadata: "@codemirror/lang-json": "npm:^6.0.1" "@codemirror/lang-sql": "npm:^6.7.0" "@dagrejs/dagre": "npm:^1.1.5" + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@eslint/compat": "npm:^2.0.0" "@hookform/devtools": "npm:^4.3.1" "@hookform/resolvers": "npm:^3.9.0"