Skip to content

Commit 6f27ddd

Browse files
fix(dashboards): list by folder, canonical folder paths, named props
The dashboard browser now asks for one folder's dashboards instead of filtering the first 500 across the workspace, folder paths use the shared segment encoder, and the README names the renumbered migrations. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S6aTRnkiu7PxYZPNPXYEMV
1 parent 51864b2 commit 6f27ddd

11 files changed

Lines changed: 82 additions & 47 deletions

File tree

‎apps/sim/app/api/workspaces/[id]/dashboards/route.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ export const GET = defineInternalJsonRoute({
1414
operation: dashboardOperations.list,
1515
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
1616
errorPolicy: internalOrchestrationErrorPolicy,
17-
mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }),
17+
mapInput: ({ params, query }) => ({
18+
workspaceId: params.id,
19+
search: query.search,
20+
folderId: query.folder === 'root' ? null : query.folder,
21+
}),
1822
useCase: listDashboards,
1923
})
2024

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { DashboardResource } from '@/components/dashboards/dashboard-resource'
2-
export default async function DashboardPage({
3-
params,
4-
}: {
2+
3+
interface DashboardPageProps {
54
params: Promise<{ workspaceId: string; dashboardId: string }>
6-
}) {
5+
}
6+
7+
export default async function DashboardPage({ params }: DashboardPageProps) {
78
return <DashboardResource {...(await params)} />
89
}

‎apps/sim/app/workspace/[workspaceId]/dashboards/dashboard-dialog.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@sim/emcn'
1212
import type { DashboardRecord } from '@/lib/api/contracts/dashboards'
1313
import type { FolderApi } from '@/lib/api/contracts/folders'
14+
import { encodeFolderPathSegment } from '@/lib/folders/paths'
1415
import { useDashboardMutation } from '@/hooks/queries/dashboards'
1516

1617
export type DashboardDialogState =
@@ -62,7 +63,7 @@ export function DashboardDialog({
6263
function pathFor(id: string): string {
6364
const row = folders.find((folder) => folder.id === id)
6465
if (!row) return ''
65-
return `${row.parentId ? pathFor(row.parentId) : ''}/${encodeURIComponent(row.name)}`
66+
return `${row.parentId ? pathFor(row.parentId) : ''}/${encodeFolderPathSegment(row.name)}`
6667
}
6768
function save() {
6869
const callbacks = {
@@ -90,7 +91,7 @@ export function DashboardDialog({
9091
break
9192
case 'createFolder':
9293
mutation.mutate(
93-
{ operation: 'createFolder', path: `${folderPath}/${encodeURIComponent(name)}` },
94+
{ operation: 'createFolder', path: `${folderPath}/${encodeFolderPathSegment(name)}` },
9495
callbacks
9596
)
9697
break
@@ -99,7 +100,7 @@ export function DashboardDialog({
99100
{
100101
operation: 'moveFolder',
101102
path: state.path,
102-
destinationPath: `${pathFor(target)}/${encodeURIComponent(name)}`,
103+
destinationPath: `${pathFor(target)}/${encodeFolderPathSegment(name)}`,
103104
},
104105
callbacks
105106
)

‎apps/sim/app/workspace/[workspaceId]/dashboards/dashboards.tsx‎

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { ChartColumn, Folder, Loader, MoreHorizontal, Pencil, Trash } from '@sim/emcn/icons'
1313
import { useRouter } from 'next/navigation'
1414
import { useQueryStates } from 'nuqs'
15+
import { encodeFolderPathSegment } from '@/lib/folders/paths'
1516
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1617
import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
1718
import {
@@ -72,7 +73,10 @@ export function Dashboards({ workspaceId }: DashboardsProps) {
7273
useWorkspaceFilesRoom(workspaceId)
7374
const setSearch = useDebouncedSearchSetter((search, options) => setState({ search }, options))
7475
const search = useSearchFilterValue(state.search, SEARCH_DEBOUNCE_MS).trim()
75-
const query = useDashboards(workspaceId, search)
76+
const query = useDashboards(
77+
workspaceId,
78+
search ? { search } : { folder: state.folderId ?? 'root' }
79+
)
7680
const folderQuery = useDashboardFolders(workspaceId)
7781
const folders = folderQuery.data?.folders ?? []
7882
const dashboards = query.data?.dashboards ?? []
@@ -90,7 +94,7 @@ export function Dashboards({ workspaceId }: DashboardsProps) {
9094
current = folders.find((item) => item.id === current?.parentId)
9195
}
9296
const folderPath = breadcrumbs.length
93-
? `/${breadcrumbs.map((item) => encodeURIComponent(item.label)).join('/')}`
97+
? `/${breadcrumbs.map((item) => encodeFolderPathSegment(item.label)).join('/')}`
9498
: ''
9599
const open = (id: string) => router.push(`/workspace/${workspaceId}/dashboards/${id}`)
96100
const folderMissing = state.folderId !== null && folderQuery.isSuccess && !folder
@@ -153,7 +157,7 @@ export function Dashboards({ workspaceId }: DashboardsProps) {
153157
(!search || item.name.toLowerCase().includes(search.toLowerCase()))
154158
)
155159
.map((item) => {
156-
const path = `${folderPath}/${encodeURIComponent(item.name)}`
160+
const path = `${folderPath}/${encodeFolderPathSegment(item.name)}`
157161
return {
158162
id: item.id,
159163
cells: {
@@ -171,24 +175,22 @@ export function Dashboards({ workspaceId }: DashboardsProps) {
171175
},
172176
}
173177
}),
174-
...dashboards
175-
.filter((item) => search || item.folderId === state.folderId)
176-
.map((item) => ({
177-
id: item.id,
178-
cells: {
179-
name: { icon: <ChartColumn />, label: item.name },
180-
updated: { label: new Date(item.updatedAt).toLocaleDateString() },
181-
actions: {
182-
content: canEdit && (
183-
<DashboardRowActions
184-
label={item.name}
185-
onEdit={() => setDialog({ kind: 'edit', dashboard: item })}
186-
onDelete={() => setDialog({ kind: 'delete', dashboard: item })}
187-
/>
188-
),
189-
},
178+
...dashboards.map((item) => ({
179+
id: item.id,
180+
cells: {
181+
name: { icon: <ChartColumn />, label: item.name },
182+
updated: { label: new Date(item.updatedAt).toLocaleDateString() },
183+
actions: {
184+
content: canEdit && (
185+
<DashboardRowActions
186+
label={item.name}
187+
onEdit={() => setDialog({ kind: 'edit', dashboard: item })}
188+
onDelete={() => setDialog({ kind: 'delete', dashboard: item })}
189+
/>
190+
),
190191
},
191-
})),
192+
},
193+
})),
192194
]}
193195
onRowClick={(id) => {
194196
if (folders.some((item) => item.id === id)) {
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { ReactNode } from 'react'
22
import { DashboardFeatureGate } from '@/components/dashboards/dashboard-feature-gate'
33

4-
export default function DashboardLayout({ children }: { children: ReactNode }) {
4+
interface DashboardLayoutProps {
5+
children: ReactNode
6+
}
7+
8+
export default function DashboardLayout({ children }: DashboardLayoutProps) {
59
return <DashboardFeatureGate>{children}</DashboardFeatureGate>
610
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/available-resources.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,13 @@ export function useAvailableResources(
118118
const { data: tables, isPending: tablesPending } = useTablesList(workspaceId, 'active', {
119119
enabled: enabled && Boolean(workspaceId),
120120
})
121-
const { data: dashboardData, isPending: dashboardsPending } = useDashboards(workspaceId, '', {
122-
enabled: enabled && dashboardsEnabled && !excludeTypes?.includes('dashboard'),
123-
})
121+
const { data: dashboardData, isPending: dashboardsPending } = useDashboards(
122+
workspaceId,
123+
{},
124+
{
125+
enabled: enabled && dashboardsEnabled && !excludeTypes?.includes('dashboard'),
126+
}
127+
)
124128
const { data: files, isPending: filesPending } = useWorkspaceFiles(workspaceId, 'active', {
125129
enabled: enabled && Boolean(workspaceId),
126130
})

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,9 +165,13 @@ function useResourceNameLookup(
165165
): Map<string, string> {
166166
const dashboardsEnabled = useFeatureFlag('dashboards')
167167
const enabled = resources.length > 0
168-
const { data: dashboardData } = useDashboards(workspaceId ?? '', '', {
169-
enabled: enabled && dashboardsEnabled,
170-
})
168+
const { data: dashboardData } = useDashboards(
169+
workspaceId ?? '',
170+
{},
171+
{
172+
enabled: enabled && dashboardsEnabled,
173+
}
174+
)
171175
const owners = [
172176
...new Set(
173177
resources

‎apps/sim/hooks/queries/dashboards.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createDashboardFolderContract,
77
deleteDashboardContract,
88
deleteDashboardFolderContract,
9+
type ListDashboardsQuery,
910
listDashboardFoldersContract,
1011
listDashboardsContract,
1112
type MoveDashboardBody,
@@ -21,20 +22,24 @@ export const DASHBOARD_STALE_TIME = 30_000
2122
export const dashboardKeys = {
2223
all: ['dashboards'] as const,
2324
lists: () => [...dashboardKeys.all, 'list'] as const,
24-
list: (workspaceId: string, search = '') =>
25-
[...dashboardKeys.lists(), workspaceId, search] as const,
25+
list: (workspaceId: string, query: ListDashboardsQuery = {}) =>
26+
[...dashboardKeys.lists(), workspaceId, query.search ?? '', query.folder ?? ''] as const,
2627
details: () => [...dashboardKeys.all, 'detail'] as const,
2728
detail: (workspaceId: string, id: string) =>
2829
[...dashboardKeys.details(), workspaceId, id] as const,
2930
folders: (workspaceId: string) => [...dashboardKeys.all, 'folders', workspaceId] as const,
3031
}
31-
export function useDashboards(workspaceId: string, search = '', options?: { enabled?: boolean }) {
32+
export function useDashboards(
33+
workspaceId: string,
34+
query: ListDashboardsQuery = {},
35+
options?: { enabled?: boolean }
36+
) {
3237
return useQuery({
33-
queryKey: dashboardKeys.list(workspaceId, search),
38+
queryKey: dashboardKeys.list(workspaceId, query),
3439
queryFn: ({ signal }) =>
3540
requestJson(listDashboardsContract, {
3641
params: { id: workspaceId },
37-
query: { search },
42+
query,
3843
signal,
3944
}),
4045
enabled: Boolean(workspaceId) && (options?.enabled ?? true),

‎apps/sim/lib/api/contracts/dashboards.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { z } from 'zod'
22
import { defineRouteContract } from '@/lib/api/contracts'
33
import { folderSchema } from '@/lib/api/contracts/folders'
4-
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
4+
import { folderIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
55

66
export const dashboardParamsSchema = z.object({
77
id: workspaceIdSchema,
@@ -44,11 +44,16 @@ export const moveDashboardBodySchema = z
4444
folderId: z.string().min(1).nullable().optional(),
4545
})
4646
.strict()
47+
export const listDashboardsQuerySchema = z.object({
48+
search: z.string().max(255).optional(),
49+
/** A folder id, or `root` for dashboards outside any folder. Omit to list every folder. */
50+
folder: z.union([z.literal('root'), folderIdSchema]).optional(),
51+
})
4752
export const listDashboardsContract = defineRouteContract({
4853
method: 'GET',
4954
path: '/api/workspaces/[id]/dashboards',
5055
params: workspaceParams,
51-
query: z.object({ search: z.string().max(255).optional() }),
56+
query: listDashboardsQuerySchema,
5257
response: {
5358
mode: 'json',
5459
schema: z.object({ dashboards: z.array(dashboardRecordSchema), truncated: z.boolean() }),
@@ -123,6 +128,7 @@ export const deleteDashboardFolderContract = defineRouteContract({
123128
response: { mode: 'json', schema: deletedResponse },
124129
})
125130
export type DashboardRecord = z.output<typeof dashboardRecordSchema>
131+
export type ListDashboardsQuery = z.input<typeof listDashboardsQuerySchema>
126132
export type CreateDashboardBody = z.input<typeof createDashboardBodySchema>
127133
export type UpdateDashboardBody = z.input<typeof updateDashboardBodySchema>
128134
export type MoveDashboardBody = z.input<typeof moveDashboardBodySchema>

‎apps/sim/lib/dashboards/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ These measures take a condition rather than a numeric field, so tables do not ne
4141
axis formatting. Pie/donut distributions can continue grouping by outcome and
4242
counting rows; their slice percentages are computed by ECharts.
4343

44-
Queries reuse the table predicate compiler and the existing read-only repeatable-read transaction guards, including statement/lock timeouts and tenant index planning. The built-in timestamp predicate leaves the indexed column uncast. Custom date extraction requires scanning matching table rows. There is no background polling. Migration 0384 adds `dashboard` to the existing folder resource enum; it does not create a dashboard table.
44+
Queries reuse the table predicate compiler and the existing read-only repeatable-read transaction guards, including statement/lock timeouts and tenant index planning. The built-in timestamp predicate leaves the indexed column uncast. Custom date extraction requires scanning matching table rows. There is no background polling. Migration 0391 adds `dashboard` to the existing folder resource enum; it does not create a dashboard table.
4545

4646
Bounds: 128 KB source, 48 blocks, 4 layout levels, 2 grouping fields, 8 measures, 12 projected columns, 500 result rows and 8 KB per returned row. Limits apply after aggregation. An explicit limit yields a labeled top-N result; unrequested group overflow is an error. API rate admission is per viewer. Errors are never turned into successful zeros. Only visible tabs mount their query observers; identical queries share React Query cache entries for one minute, and Refresh requests fresh data.
4747

@@ -89,8 +89,8 @@ New dashboard definitions are unlisted workspace files. Dashboard APIs select th
8989
content type explicitly, and the resource picker requests dashboards separately from Files.
9090
Versions, billing, cleanup, and complete workspace copies still include unlisted resources.
9191

92-
Migration 0385 adds the defaulted discovery column and a temporary bridge for old upload
93-
writers during rollout. Script migration 0025 backfills non-workspace uploads in id-keyed
92+
Migration 0392 adds the defaulted discovery column and a temporary bridge for old upload
93+
writers during rollout. Script migration 0027 backfills non-workspace uploads in id-keyed
9494
pages of 1,000, including archived uploads, without changing content revisions or ownership.
9595
It contains no dashboard backfill: dashboards have not shipped. Remove the bridge in a
9696
later migration after all discovery-aware writers are deployed.

0 commit comments

Comments
 (0)