diff --git a/templates/analytics/actions/dashboard-mutation-api.ts b/templates/analytics/actions/dashboard-mutation-api.ts index 00d1563cf2..561094fed3 100644 --- a/templates/analytics/actions/dashboard-mutation-api.ts +++ b/templates/analytics/actions/dashboard-mutation-api.ts @@ -17,6 +17,8 @@ type DashboardPatch = { columns?: number; filters?: unknown[]; variables?: Record; + /** Id of another dashboard to nest this one under in the sidebar. */ + parentId?: string; }; type PanelPatch = { diff --git a/templates/analytics/actions/update-dashboard.ts b/templates/analytics/actions/update-dashboard.ts index 22b4ae534c..ca2514d34f 100644 --- a/templates/analytics/actions/update-dashboard.ts +++ b/templates/analytics/actions/update-dashboard.ts @@ -277,6 +277,14 @@ export function validateDashboardConfig( if (typeof config.name !== "string" || config.name.trim().length === 0) { return "config.name is required (non-empty string) — without it the dashboard renders as a blank row in the sidebar"; } + if (config.parentId !== undefined && config.parentId !== null) { + if ( + typeof config.parentId !== "string" || + config.parentId.trim().length === 0 + ) { + return "config.parentId must be a non-empty dashboard id (or omitted) — it nests this dashboard under that parent in the sidebar"; + } + } // Filter ID collisions cause two controls to read/write the same URL param. // For paired start/end dates use a single date-range filter — the FilterBar // expands it to Start / End at runtime, so the SQL can still diff --git a/templates/analytics/app/components/layout/Sidebar.tsx b/templates/analytics/app/components/layout/Sidebar.tsx index 4791bb38a6..4ab46fca14 100644 --- a/templates/analytics/app/components/layout/Sidebar.tsx +++ b/templates/analytics/app/components/layout/Sidebar.tsx @@ -30,7 +30,14 @@ import { type QueryKey, } from "@tanstack/react-query"; import { useTheme } from "next-themes"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { + useState, + useEffect, + useCallback, + useRef, + useMemo, + Fragment, +} from "react"; import { Link, useLocation, useNavigate } from "react-router"; import { toast } from "sonner"; @@ -51,6 +58,8 @@ type SidebarDashboard = { subviews?: DashboardSubview[]; source: "static" | "sql"; visibility?: Visibility; + /** Id of the dashboard this one nests under in the sidebar, if any. */ + parentId?: string; }; import { DevDatabaseLink, @@ -1024,6 +1033,7 @@ type SqlDashboardListItem = { id: string; name: string; visibility?: Visibility; + parentId?: string; }; async function fetchSqlDashboards( @@ -1043,6 +1053,10 @@ async function fetchSqlDashboards( d.visibility === "org" || d.visibility === "public" ? (d.visibility as Visibility) : ("private" as Visibility), + parentId: + typeof d.parentId === "string" && d.parentId.trim().length > 0 + ? d.parentId + : undefined, })); } catch { return []; @@ -1434,6 +1448,7 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { name: d.name, source: "sql", visibility: d.visibility, + parentId: d.parentId, })); const all = [...staticItems, ...sqlItems]; if (dashboardSortMode === "alphabetical") { @@ -1471,12 +1486,40 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { [visibleDashboards, dashFilter], ); + // Group dashboards that declare a parentId beneath their parent. Nesting is + // intentionally one level deep: a dashboard only nests when its parent is + // itself top-level. Orphans (parent missing/filtered out), self-references, + // cycles, and deeper descendants all fall back to top level so nothing is + // ever hidden. + const dashboardChildren = useMemo>(() => { + const byId = new Map(filteredDashboards.map((d) => [d.id, d])); + const hasValidParent = (d: SidebarDashboard) => + !!d.parentId && d.parentId !== d.id && byId.has(d.parentId); + const byParent = new Map(); + for (const d of filteredDashboards) { + if (!hasValidParent(d)) continue; + const parent = byId.get(d.parentId as string); + if (!parent || hasValidParent(parent)) continue; + const arr = byParent.get(d.parentId as string) ?? []; + arr.push(d); + byParent.set(d.parentId as string, arr); + } + return byParent; + }, [filteredDashboards]); + + const topLevelDashboards = useMemo(() => { + const childIds = new Set(); + for (const arr of dashboardChildren.values()) + for (const c of arr) childIds.add(c.id); + return filteredDashboards.filter((d) => !childIds.has(d.id)); + }, [filteredDashboards, dashboardChildren]); + const displayedDashboards = useMemo( () => dashShowAll - ? filteredDashboards - : filteredDashboards.slice(0, SIDEBAR_PREVIEW_COUNT), - [filteredDashboards, dashShowAll], + ? topLevelDashboards + : topLevelDashboards.slice(0, SIDEBAR_PREVIEW_COUNT), + [topLevelDashboards, dashShowAll], ); const handleDashboardDelete = useCallback( @@ -2032,27 +2075,54 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) { onDragEnd={handleDashboardDragEnd} > d.id)} + items={displayedDashboards.flatMap((d) => [ + d.id, + ...(dashboardChildren.get(d.id) ?? []).map((c) => c.id), + ])} strategy={verticalListSortingStrategy} >
- {displayedDashboards.map((d) => ( - - ))} - {filteredDashboards.length > SIDEBAR_PREVIEW_COUNT && ( + {displayedDashboards.map((d) => { + const children = dashboardChildren.get(d.id) ?? []; + return ( + + + {children.length > 0 && ( +
+ {children.map((child) => ( + + ))} +
+ )} +
+ ); + })} + {topLevelDashboards.length > SIDEBAR_PREVIEW_COUNT && ( diff --git a/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts b/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts index 6147b10d63..cc0509bf97 100644 --- a/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts +++ b/templates/analytics/app/pages/adhoc/sql-dashboard/types.ts @@ -114,6 +114,13 @@ export interface SqlPanel { export interface SqlDashboardConfig { name: string; description?: string; + /** + * Optional id of another dashboard this one nests under. When set, the + * sidebar renders this dashboard indented beneath its parent instead of at + * the top level. Orphans (parent missing/inaccessible) fall back to the top + * level. Self-references and cycles are ignored by the renderer. + */ + parentId?: string; catalog?: { templateId?: string; templateVersion?: string; diff --git a/templates/analytics/changelog/2026-06-27-dashboards-can-now-nest-under-a-parent-in-the-sidebar-via-a-.md b/templates/analytics/changelog/2026-06-27-dashboards-can-now-nest-under-a-parent-in-the-sidebar-via-a-.md new file mode 100644 index 0000000000..33ba95d793 --- /dev/null +++ b/templates/analytics/changelog/2026-06-27-dashboards-can-now-nest-under-a-parent-in-the-sidebar-via-a-.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-06-27 +--- + +Dashboards can now nest under a parent in the sidebar via a parentId field