Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1365636
feat(analytics): add strategic accounts management actions
builderio-bot Jun 25, 2026
b30fdb1
feat(analytics): add strategic accounts schema and pipe-separated output
builderio-bot Jun 26, 2026
2af7898
chore: add data privacy and security guidelines
builderio-bot Jun 26, 2026
158f780
feat: add cards chart type for displaying data as individual cards
builderio-bot Jun 26, 2026
5db9e5a
fix: remove accounts filter container from SQL dashboard
builderio-bot Jun 26, 2026
81d78fb
feat(analytics): add strategic account contacts with multi-child support
builderio-bot Jun 26, 2026
a4a9668
Remove unused analytics query templates
builderio-bot Jun 26, 2026
494cd5d
feat(analytics): add strategic accounts roster generation action
builderio-bot Jun 26, 2026
9296e7d
feat: add embedded extension support to SQL dashboards
builderio-bot Jun 26, 2026
9285334
chore: remove .builderrules file
builderio-bot Jun 26, 2026
acdb738
Resolve conflicts and add generative UI documentation
builderio-bot Jun 26, 2026
620b067
Fix strategic accounts metadata preservation and SQL access control
builderio-bot Jun 26, 2026
278ca16
fix: format code to pass pnpm fmt:check validation
builderio-bot Jun 26, 2026
b3c1e00
Remove strategic accounts roster generation action
builderio-bot Jun 26, 2026
9a6c342
Discard changes to templates/analytics/app/components/layout/Sidebar.tsx
builderio-bot Jun 26, 2026
da21711
Discard changes to templates/analytics/app/pages/adhoc/sql-dashboard/…
builderio-bot Jun 26, 2026
3e9a76d
Discard changes to templates/analytics/app/pages/adhoc/sql-dashboard/…
builderio-bot Jun 26, 2026
53ee5ef
Discard changes to templates/analytics/.gitignore
builderio-bot Jun 26, 2026
f609525
Add sidebar dashboard nesting via parentId field
builderio-bot Jun 27, 2026
200a5d5
Discard changes to pnpm-lock.yaml
builderio-bot Jun 27, 2026
f17b4fa
Fix dashboard nesting to prevent hidden items and cycles
builderio-bot Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions templates/analytics/actions/dashboard-mutation-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ type DashboardPatch = {
columns?: number;
filters?: unknown[];
variables?: Record<string, string>;
/** Id of another dashboard to nest this one under in the sidebar. */
parentId?: string;
};

type PanelPatch = {
Expand Down
8 changes: 8 additions & 0 deletions templates/analytics/actions/update-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>Start / <id>End at runtime, so the SQL can still
Expand Down
116 changes: 93 additions & 23 deletions templates/analytics/app/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down Expand Up @@ -1024,6 +1033,7 @@ type SqlDashboardListItem = {
id: string;
name: string;
visibility?: Visibility;
parentId?: string;
};

async function fetchSqlDashboards(
Expand All @@ -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 [];
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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<Map<string, SidebarDashboard[]>>(() => {
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<string, SidebarDashboard[]>();
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<string>();
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(
Expand Down Expand Up @@ -2032,27 +2075,54 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
onDragEnd={handleDashboardDragEnd}
>
<SortableContext
items={displayedDashboards.map((d) => d.id)}
items={displayedDashboards.flatMap((d) => [

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nested dashboard drag ordering uses a different ID order than the rendered list

The sortable list now flattens each parent immediately followed by its children, but handleDashboardDragEnd still computes indices from visibleDashboards, whose order can differ. That mismatch can apply arrayMove() to the wrong positions once a child is visually nested under a parent.

Additional Info
SortableContext items at lines 2078-2081 use displayedDashboards + dashboardChildren, while handleDashboardDragEnd (unchanged at ~1838-1843) still uses visibleDashboards.map(d => d.id).

Fix in Builder

d.id,
...(dashboardChildren.get(d.id) ?? []).map((c) => c.id),
])}
strategy={verticalListSortingStrategy}
>
<div className="ms-4 min-w-0 space-y-0.5">
{displayedDashboards.map((d) => (
<SortableDashboardItem
key={d.id}
d={d}
isActive={activeDashboardId === d.id}
location={location}
favoriteIds={favoriteIds}
onToggleFavorite={toggleFavorite}
onDelete={handleDashboardDelete}
onRename={handleDashboardRename}
onArchive={handleDashboardArchive}
onSetVisibility={handleDashboardSetVisibility}
onPrefetch={prefetchDashboard}
views={allViewsMap[d.id]}
/>
))}
{filteredDashboards.length > SIDEBAR_PREVIEW_COUNT && (
{displayedDashboards.map((d) => {
const children = dashboardChildren.get(d.id) ?? [];
return (
<Fragment key={d.id}>
<SortableDashboardItem
d={d}
isActive={activeDashboardId === d.id}
location={location}
favoriteIds={favoriteIds}
onToggleFavorite={toggleFavorite}
onDelete={handleDashboardDelete}
onRename={handleDashboardRename}
onArchive={handleDashboardArchive}
onSetVisibility={handleDashboardSetVisibility}
onPrefetch={prefetchDashboard}
views={allViewsMap[d.id]}
/>
{children.length > 0 && (
<div className="ms-3 space-y-0.5 border-s border-sidebar-border/60 ps-1">
{children.map((child) => (
<SortableDashboardItem
key={child.id}
d={child}
isActive={activeDashboardId === child.id}
location={location}
favoriteIds={favoriteIds}
onToggleFavorite={toggleFavorite}
onDelete={handleDashboardDelete}
onRename={handleDashboardRename}
onArchive={handleDashboardArchive}
onSetVisibility={handleDashboardSetVisibility}
onPrefetch={prefetchDashboard}
views={allViewsMap[child.id]}
/>
))}
</div>
)}
</Fragment>
);
})}
{topLevelDashboards.length > SIDEBAR_PREVIEW_COUNT && (
<button
onClick={() => setDashShowAll(!dashShowAll)}
className="flex items-center gap-1 px-3 py-1 text-[11px] text-muted-foreground/70 hover:text-primary"
Expand All @@ -2061,7 +2131,7 @@ export function Sidebar({ mobile }: { mobile?: boolean } = {}) {
? t("sidebar.showLess")
: t("sidebar.showMore", {
count:
filteredDashboards.length -
topLevelDashboards.length -
SIDEBAR_PREVIEW_COUNT,
})}
</button>
Expand Down
7 changes: 7 additions & 0 deletions templates/analytics/app/pages/adhoc/sql-dashboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +117 to +123

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nested dashboards lose parentId when saved from the dashboard page

This adds parentId to the stored dashboard config, but fetchDashboard()/prefetch on the dashboard page still rebuild the config without that field before later calling saveDashboard(updated). Renaming or editing a nested dashboard can therefore silently drop parentId and move it back to top level.

Additional Info
Introduced field at types.ts lines 117-123, but app/pages/adhoc/sql-dashboard/index.tsx fetchDashboard() and Sidebar prefetch both omit data.parentId when reconstructing SqlDashboardConfig.

Fix in Builder

catalog?: {
templateId?: string;
templateVersion?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
type: added
date: 2026-06-27
---

Dashboards can now nest under a parent in the sidebar via a parentId field
Loading