Skip to content

Commit fff6606

Browse files
authored
fix(folders): show the folder trail in table and knowledge base headers (#6515)
* fix(folders): show the folder trail in table and knowledge base headers A table or knowledge base opened from inside a folder rendered `Tables / name`, dropping every folder between it and the root — while a file's header showed the full `Files / docs / name` path. The detail pages never read the resource's own `folderId`, so the trail could not include it. Converge all six foldered surfaces on one builder instead of fixing the two headers in place: - `folderAncestorChain(folderId, lookup)` in `lib/folders/tree.ts` is now the single upward walk. Both `getFolderPath` variants delegate to it; the two lock predicates deliberately keep their inline walks, which short-circuit at the first locked ancestor on a per-row render path. - `folderBreadcrumbItems` takes a `trailing` slot for detail pages, as a discriminated union so an open-folder rename cannot be passed alongside it and silently dropped. - `useFolderAncestors` owns the tree plus the `foldersResolved` staleness rule; `useFolderNavigation` now delegates to it. - `FOLDERED_RESOURCE_HEADERS` owns each resource's root label, root icon, and list path, which seven sites previously restated. Files' list trail moved off splitting the materialized `path` string, which could not tell two same-named siblings apart, onto the shared parentId walk. Also fixes the Files loading trail, whose folder crumbs used the nuqs setter while rendering on the file detail route — appending `?folderId=` to the open file's own URL instead of navigating to the list. * fix(knowledge): confirm before a breadcrumb navigates away from an unsaved chunk * fix(knowledge): source the pluralized root label from the folder registry * chore(folders): tighten the shared breadcrumb docs and dedupe the ancestry type
1 parent c5db403 commit fff6606

20 files changed

Lines changed: 821 additions & 467 deletions

File tree

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,114 @@
11
import type { ElementType } from 'react'
2+
import { folderAncestorChain } from '@/lib/folders/tree'
23
import type {
34
BreadcrumbEditing,
45
BreadcrumbItem,
56
DropdownOption,
67
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
7-
import type { WorkflowFolder } from '@/stores/folders/types'
88

9-
export interface FolderBreadcrumbItemsOptions {
10-
/** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */
9+
/**
10+
* Structural rather than `WorkflowFolder` so the Files tree — same `folder` table, own routes
11+
* and row type (see `servedFolderResourceTypeSchema` in `@/lib/api/contracts/folders`) — shares
12+
* this code path instead of forking it.
13+
*/
14+
export interface BreadcrumbFolder {
15+
id: string
16+
name: string
17+
parentId: string | null
18+
}
19+
20+
const EMPTY_CHAIN: never[] = []
21+
22+
/**
23+
* Root-first ancestor chain, that folder last, or empty when it does not reach the root —
24+
* where {@link folderAncestorChain} would hand back the part it walked.
25+
*
26+
* A partial path is not a shorter path, it is a wrong one: it claims the deepest folder it
27+
* resolved sits at the workspace root. Falling back to the root title is the honest render.
28+
* Completeness is `chain[0].parentId === null`, which also rejects a cycle. Callers must pass
29+
* the complete tree — see `FolderAncestors.foldersResolved`.
30+
*/
31+
export function breadcrumbFolderChain<T extends BreadcrumbFolder>(
32+
folderId: string | null | undefined,
33+
folderById: ReadonlyMap<string, T>
34+
): T[] {
35+
const chain = folderAncestorChain(folderId, (id) => folderById.get(id))
36+
return chain.length === 0 || chain[0].parentId === null ? chain : EMPTY_CHAIN
37+
}
38+
39+
interface FolderBreadcrumbItemsBase {
40+
/** Root crumb label — the page's own name ("Knowledge bases", "Tables"). */
1141
rootLabel: string
1242
rootIcon?: ElementType
13-
/** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */
14-
breadcrumbs: WorkflowFolder[]
43+
/** Root-first ancestor chain, from {@link folderAncestorChain}. */
44+
breadcrumbs: BreadcrumbFolder[]
1545
/** Called with the folder to open, or `null` for the workspace root. */
1646
onNavigate: (folderId: string | null) => void
47+
}
48+
49+
/** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */
50+
interface FolderListBreadcrumbOptions extends FolderBreadcrumbItemsBase {
1751
/** Menu attached to the open folder's crumb (rename, delete, …). */
1852
currentFolderActions?: DropdownOption[]
1953
/** Inline rename bound to the open folder's crumb. */
2054
currentFolderEditing?: BreadcrumbEditing
55+
trailing?: never
56+
}
57+
58+
/** A detail page: the open resource is where you are, so every folder crumb navigates. */
59+
interface FolderDetailBreadcrumbOptions extends FolderBreadcrumbItemsBase {
60+
/**
61+
* Crumbs appended after the folder trail — the resource open on a detail page, plus
62+
* anything nested under it (a knowledge base's document, that document's chunk).
63+
*/
64+
trailing: BreadcrumbItem[]
65+
currentFolderActions?: never
66+
currentFolderEditing?: never
2167
}
2268

2369
/**
24-
* Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header`
25-
* renders.
70+
* The two modes are disjoint by construction rather than by convention: an open-folder rename
71+
* or menu acts on the folder you are inside, which on a detail page you are not. Expressed as
72+
* a union so passing both is a compile error instead of a handler that silently never fires.
73+
*/
74+
export type FolderBreadcrumbItemsOptions =
75+
| FolderListBreadcrumbOptions
76+
| FolderDetailBreadcrumbOptions
77+
78+
const NO_TRAILING_CRUMBS: BreadcrumbItem[] = []
79+
80+
/**
81+
* Builds the `BreadcrumbItem[]` for a list page (`Tables / Reports`) or a detail page
82+
* (`Tables / Reports / Q3`).
2683
*
2784
* A plain builder rather than a component: `Resource.Header` already owns every piece of
2885
* breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow
29-
* tooltips, and the rule that a single-element trail renders as a plain page title. A
30-
* sibling crumb component would have to fork all of it, which is exactly what this shared
31-
* directory exists to prevent.
32-
*
33-
* The trail always starts with the root crumb, so at the workspace root the result has
34-
* length 1 and the header renders the page title unchanged.
86+
* tooltips, and the rule that a single-element trail renders as a plain page title. A sibling
87+
* crumb component would have to fork all of it, which is what this directory exists to prevent.
3588
*/
36-
export function folderBreadcrumbItems({
37-
rootLabel,
38-
rootIcon,
39-
breadcrumbs,
40-
onNavigate,
41-
currentFolderActions,
42-
currentFolderEditing,
43-
}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] {
89+
export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): BreadcrumbItem[] {
90+
const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options
91+
const trailing = options.trailing ?? NO_TRAILING_CRUMBS
92+
4493
const items: BreadcrumbItem[] = [
4594
{ label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) },
4695
]
4796

4897
breadcrumbs.forEach((folder, index) => {
49-
const isCurrent = index === breadcrumbs.length - 1
50-
/** The open folder is where you already are, so its crumb is not a navigation target. */
98+
/** Where you already are — and on a detail page that is a trailing crumb, not a folder. */
99+
const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1
51100
items.push({
52101
label: folder.name,
53-
onClick: isCurrent ? undefined : () => onNavigate(folder.id),
54-
dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined,
55-
editing: isCurrent ? currentFolderEditing : undefined,
102+
onClick: isOpenFolder ? undefined : () => onNavigate(folder.id),
103+
dropdownItems:
104+
isOpenFolder && options.currentFolderActions?.length
105+
? options.currentFolderActions
106+
: undefined,
107+
editing: isOpenFolder ? options.currentFolderEditing : undefined,
56108
})
57109
})
58110

111+
items.push(...trailing)
112+
59113
return items
60114
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { ElementType } from 'react'
2+
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
3+
import type { FolderResourceType } from '@/lib/api/contracts/folders'
4+
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'
5+
6+
/**
7+
* The foldered resources that render a `Resource.Header` breadcrumb trail. A subset of
8+
* {@link FolderResourceType}: workflows are foldered too, but they live in the editor sidebar
9+
* rather than on a list page with a header.
10+
*/
11+
export type FolderedHeaderResourceType = Extract<
12+
FolderResourceType,
13+
'file' | 'knowledge_base' | 'table'
14+
>
15+
16+
export interface FolderedResourceHeaderMeta {
17+
/** Root crumb label, and the page title at the workspace root. */
18+
rootLabel: string
19+
/** Icon on the root crumb, which is also what opens the header's "Path" popover. */
20+
rootIcon: ElementType
21+
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
22+
listSegment: string
23+
}
24+
25+
/**
26+
* The per-resource facts a foldered header needs, in one place.
27+
*
28+
* Each was previously restated at every surface rendering that resource — list page, detail
29+
* page, and for knowledge bases the document and chunk views — which is how one trail ends up
30+
* labelled differently depending on which page you reached it from.
31+
*/
32+
export const FOLDERED_RESOURCE_HEADERS: Record<
33+
FolderedHeaderResourceType,
34+
FolderedResourceHeaderMeta
35+
> = {
36+
file: { rootLabel: 'Files', rootIcon: FileIcon, listSegment: 'files' },
37+
knowledge_base: { rootLabel: 'Knowledge bases', rootIcon: Database, listSegment: 'knowledge' },
38+
table: { rootLabel: 'Tables', rootIcon: TableIcon, listSegment: 'tables' },
39+
}
40+
41+
/**
42+
* Href of a foldered resource's list page, opened at `folderId` or at its workspace root.
43+
*
44+
* Detail pages navigate to a different route, so their breadcrumb folder crumbs cannot use the
45+
* nuqs setter — it only mutates the query of the current path.
46+
*/
47+
export function folderedResourceListHref(
48+
resourceType: FolderedHeaderResourceType,
49+
workspaceId: string,
50+
folderId: string | null
51+
): string {
52+
const { listSegment } = FOLDERED_RESOURCE_HEADERS[resourceType]
53+
return folderListHref(`/workspace/${workspaceId}/${listSegment}`, folderId)
54+
}

apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
* @vitest-environment node
33
*/
44
import { describe, expect, it, vi } from 'vitest'
5-
import { folderBreadcrumbItems } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
5+
import { folderAncestorChain } from '@/lib/folders/tree'
6+
import {
7+
breadcrumbFolderChain,
8+
folderBreadcrumbItems,
9+
} from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
610
import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming'
711
import {
812
folderRowId,
@@ -238,4 +242,92 @@ describe('folderBreadcrumbItems', () => {
238242
expect(items[2].editing).toBe(currentFolderEditing)
239243
expect(items[2].dropdownItems).toBe(currentFolderActions)
240244
})
245+
246+
it('appends the trailing crumbs of a detail page after the folder chain', () => {
247+
const items = folderBreadcrumbItems({
248+
rootLabel: 'Tables',
249+
breadcrumbs: [makeFolder('root', null, { name: 'Alpha' })],
250+
onNavigate: vi.fn(),
251+
trailing: [{ label: 'Q3' }],
252+
})
253+
expect(items.map((item) => item.label)).toEqual(['Tables', 'Alpha', 'Q3'])
254+
})
255+
256+
it('makes every folder crumb navigable once a trailing crumb is where you are', () => {
257+
const onNavigate = vi.fn()
258+
const items = folderBreadcrumbItems({
259+
rootLabel: 'Tables',
260+
breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')],
261+
onNavigate,
262+
trailing: [{ label: 'Q3' }],
263+
})
264+
265+
items[2].onClick?.()
266+
expect(onNavigate).toHaveBeenCalledWith('leaf')
267+
})
268+
269+
it('leaves the deepest folder crumb plain on a detail page — the rename and menu are list-only', () => {
270+
const items = folderBreadcrumbItems({
271+
rootLabel: 'Tables',
272+
breadcrumbs: [makeFolder('leaf')],
273+
onNavigate: vi.fn(),
274+
trailing: [{ label: 'Q3' }],
275+
})
276+
277+
expect(items[1].dropdownItems).toBeUndefined()
278+
expect(items[1].editing).toBeUndefined()
279+
})
280+
})
281+
282+
describe('breadcrumbFolderChain', () => {
283+
function mapOf(...folders: WorkflowFolder[]) {
284+
return new Map(folders.map((folder) => [folder.id, folder]))
285+
}
286+
287+
it('returns nothing at the workspace root', () => {
288+
expect(breadcrumbFolderChain(null, mapOf(makeFolder('a')))).toEqual([])
289+
expect(breadcrumbFolderChain(undefined, mapOf(makeFolder('a')))).toEqual([])
290+
})
291+
292+
it('walks parentId up to the root and returns the chain root-first', () => {
293+
const chain = breadcrumbFolderChain(
294+
'leaf',
295+
mapOf(makeFolder('root'), makeFolder('mid', 'root'), makeFolder('leaf', 'mid'))
296+
)
297+
expect(chain.map((folder) => folder.id)).toEqual(['root', 'mid', 'leaf'])
298+
})
299+
300+
it('collapses the whole chain when an ancestor does not resolve, rather than skipping a level', () => {
301+
const chain = breadcrumbFolderChain('leaf', mapOf(makeFolder('leaf', 'gone')))
302+
expect(chain).toEqual([])
303+
})
304+
305+
it('collapses a parent cycle the DB permits between constraint checks, rather than hanging', () => {
306+
const chain = breadcrumbFolderChain('a', mapOf(makeFolder('a', 'b'), makeFolder('b', 'a')))
307+
expect(chain).toEqual([])
308+
})
309+
310+
it('collapses a chain the folder map is still too incomplete to root', () => {
311+
const chain = breadcrumbFolderChain(
312+
'leaf',
313+
mapOf(makeFolder('mid', 'root'), makeFolder('leaf', 'mid'))
314+
)
315+
expect(chain).toEqual([])
316+
})
317+
})
318+
319+
describe('folderAncestorChain', () => {
320+
it('keeps the part it walked when a link does not resolve — the breadcrumb rule is a wrapper', () => {
321+
const folders: Record<string, WorkflowFolder> = { leaf: makeFolder('leaf', 'gone') }
322+
const chain = folderAncestorChain('leaf', (id) => folders[id])
323+
expect(chain.map((folder) => folder.id)).toEqual(['leaf'])
324+
})
325+
326+
it('stops on a cycle instead of looping forever', () => {
327+
const folders: Record<string, WorkflowFolder> = {
328+
a: makeFolder('a', 'b'),
329+
b: makeFolder('b', 'a'),
330+
}
331+
expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a'])
332+
})
241333
})

apps/sim/app/workspace/[workspaceId]/components/folders/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
export type { FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
2-
export { folderBreadcrumbItems } from './folder-breadcrumbs'
1+
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
2+
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
33
export { FolderContextMenu } from './folder-context-menu'
44
export { nextUntitledFolderName } from './folder-naming'
55
export type { FolderRowOptions } from './folder-row'
66
export { folderRow } from './folder-row'
77
export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id'
88
export { folderRowId, parseFolderedRowId } from './folder-row-id'
9+
export type {
10+
FolderedHeaderResourceType,
11+
FolderedResourceHeaderMeta,
12+
} from './foldered-resources'
13+
export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources'
914
export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options'
1015
export {
1116
buildDescendantIndex,
@@ -18,6 +23,8 @@ export {
1823
export type { SortableResource } from './resource-sort'
1924
export { sortResources } from './resource-sort'
2025
export { folderNavParsers, folderNavUrlKeys } from './search-params'
26+
export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors'
27+
export { useFolderAncestors } from './use-folder-ancestors'
2128
export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation'
2229
export { useFolderNavigation } from './use-folder-navigation'
2330
export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop'

apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,13 @@ export const folderNavUrlKeys = {
2323
history: 'push',
2424
clearOnDefault: true,
2525
} as const
26+
27+
/**
28+
* Href of a foldered list page opened at `folderId`, or of its workspace root when `null`.
29+
*
30+
* Lives here so a hand-built link cannot drift from {@link folderNavParsers} on the wire key.
31+
*/
32+
export function folderListHref(listPath: string, folderId: string | null): string {
33+
if (!folderId) return listPath
34+
return `${listPath}?${new URLSearchParams({ folderId })}`
35+
}

0 commit comments

Comments
 (0)