GeezerVison is showing github.com — fix(folders): show the folder trail in table and knowledge base headers · simstudioai/sim@6379114
File treeExpand file treeCollapse file tree Open diff view settings app/workspace/[workspaceId] settings/components/recently-deleted
Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +83-19Lines changed: 83 additions & 19 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 1 | 1 | import type { ElementType } from 'react' | | 2 | +import { folderAncestorChain } from '@/lib/folders/tree' | 2 | 3 | import type { | 3 | 4 | BreadcrumbEditing, | 4 | 5 | BreadcrumbItem, | 5 | 6 | DropdownOption, | 6 | 7 | } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' | 7 | | -import type { WorkflowFolder } from '@/stores/folders/types' | 8 | 8 |
| 9 | | -export interface FolderBreadcrumbItemsOptions { | | 9 | +/** | | 10 | + * The only thing a breadcrumb trail needs from a folder tree node. Structural rather than | | 11 | + * `WorkflowFolder` so the Files tree builds its trail through this same code path instead of | | 12 | + * forking one: file folders live in the same `folder` table, but they are served by their own | | 13 | + * routes and carry their own row type (see the `servedFolderResourceTypeSchema` docs in | | 14 | + * `@/lib/api/contracts/folders` for why that split exists and is staying). | | 15 | + */ | | 16 | +export interface BreadcrumbFolder { | | 17 | + id: string | | 18 | + name: string | | 19 | + parentId: string | null | | 20 | +} | | 21 | + | | 22 | +/** | | 23 | + * Root-first ancestor chain for a breadcrumb trail, that folder last. Empty at the workspace | | 24 | + * root — and empty for a chain that does not reach the root, where {@link folderAncestorChain} | | 25 | + * would hand back the part it walked. | | 26 | + * | | 27 | + * A header falls back to the root title rather than rendering a trail that silently skips a | | 28 | + * level: a partial path is not a shorter path, it is a wrong one, claiming the deepest folder | | 29 | + * it resolved sits at the workspace root. A chain is complete exactly when its first element | | 30 | + * has no parent, which also rejects a cycle (the DB permits one between constraint checks). | | 31 | + * | | 32 | + * `folderById` must therefore be the complete tree — see `FolderAncestors.foldersResolved`; | | 33 | + * a partially loaded map reads as an orphan and collapses. | | 34 | + */ | | 35 | +export function breadcrumbFolderChain<T extends BreadcrumbFolder>( | | 36 | + folderId: string | null | undefined, | | 37 | + folderById: ReadonlyMap<string, T> | | 38 | +): T[] { | | 39 | + const chain = folderAncestorChain(folderId, (id) => folderById.get(id)) | | 40 | + return chain.length === 0 || chain[0].parentId === null ? chain : EMPTY_CHAIN | | 41 | +} | | 42 | + | | 43 | +const EMPTY_CHAIN: never[] = [] | | 44 | + | | 45 | +interface FolderBreadcrumbItemsBase { | 10 | 46 | /** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */ | 11 | 47 | rootLabel: string | 12 | 48 | rootIcon?: ElementType | 13 | | - /** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */ | 14 | | - breadcrumbs: WorkflowFolder[] | | 49 | + /** Root-first ancestor chain, from {@link folderAncestorChain}. */ | | 50 | + breadcrumbs: BreadcrumbFolder[] | 15 | 51 | /** Called with the folder to open, or `null` for the workspace root. */ | 16 | 52 | onNavigate: (folderId: string | null) => void | | 53 | +} | | 54 | + | | 55 | +/** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */ | | 56 | +interface FolderListBreadcrumbOptions extends FolderBreadcrumbItemsBase { | 17 | 57 | /** Menu attached to the open folder's crumb (rename, delete, …). */ | 18 | 58 | currentFolderActions?: DropdownOption[] | 19 | 59 | /** Inline rename bound to the open folder's crumb. */ | 20 | 60 | currentFolderEditing?: BreadcrumbEditing | | 61 | + trailing?: never | 21 | 62 | } | 22 | 63 |
| | 64 | +/** A detail page: the open resource is where you are, so every folder crumb navigates. */ | | 65 | +interface FolderDetailBreadcrumbOptions extends FolderBreadcrumbItemsBase { | | 66 | + /** | | 67 | + * Crumbs appended after the folder trail — the resource open on a detail page, plus | | 68 | + * anything nested under it (a knowledge base's document, that document's chunk). | | 69 | + */ | | 70 | + trailing: BreadcrumbItem[] | | 71 | + currentFolderActions?: never | | 72 | + currentFolderEditing?: never | | 73 | +} | | 74 | + | | 75 | +/** | | 76 | + * The two modes are disjoint by construction rather than by convention: an open-folder rename | | 77 | + * or menu acts on the folder you are inside, which on a detail page you are not. Expressed as | | 78 | + * a union so passing both is a compile error instead of a handler that silently never fires. | | 79 | + */ | | 80 | +export type FolderBreadcrumbItemsOptions = | | 81 | + | FolderListBreadcrumbOptions | | 82 | + | FolderDetailBreadcrumbOptions | | 83 | + | | 84 | +const NO_TRAILING_CRUMBS: BreadcrumbItem[] = [] | | 85 | + | 23 | 86 | /** | 24 | 87 | * Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header` | 25 | | - * renders. | | 88 | + * renders, for a list page (`Tables / Reports`) or a detail page (`Tables / Reports / Q3`). | 26 | 89 | * | 27 | 90 | * A plain builder rather than a component: `Resource.Header` already owns every piece of | 28 | 91 | * breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow | 29 | 92 | * tooltips, and the rule that a single-element trail renders as a plain page title. A | 30 | 93 | * sibling crumb component would have to fork all of it, which is exactly what this shared | 31 | 94 | * directory exists to prevent. | 32 | 95 | * | 33 | | - * The trail always starts with the root crumb, so at the workspace root the result has | | 96 | + * The trail always starts with the root crumb, so a list page at the workspace root returns | 34 | 97 | * length 1 and the header renders the page title unchanged. | 35 | 98 | */ | 36 | | -export function folderBreadcrumbItems({ | 37 | | - rootLabel, | 38 | | - rootIcon, | 39 | | - breadcrumbs, | 40 | | - onNavigate, | 41 | | - currentFolderActions, | 42 | | - currentFolderEditing, | 43 | | -}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { | | 99 | +export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { | | 100 | + const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options | | 101 | + const trailing = options.trailing ?? NO_TRAILING_CRUMBS | | 102 | + | 44 | 103 | const items: BreadcrumbItem[] = [ | 45 | 104 | { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, | 46 | 105 | ] | 47 | 106 |
| 48 | 107 | 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. */ | | 108 | + /** Where you already are — and on a detail page that is a trailing crumb, not a folder. */ | | 109 | + const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 | 51 | 110 | items.push({ | 52 | 111 | label: folder.name, | 53 | | - onClick: isCurrent ? undefined : () => onNavigate(folder.id), | 54 | | - dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined, | 55 | | - editing: isCurrent ? currentFolderEditing : undefined, | | 112 | + onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), | | 113 | + dropdownItems: | | 114 | + isOpenFolder && options.currentFolderActions?.length | | 115 | + ? options.currentFolderActions | | 116 | + : undefined, | | 117 | + editing: isOpenFolder ? options.currentFolderEditing : undefined, | 56 | 118 | }) | 57 | 119 | }) | 58 | 120 |
| | 121 | + items.push(...trailing) | | 122 | + | 59 | 123 | return items | 60 | 124 | } |
Collapse file Copy file name to clipboard +55Lines changed: 55 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 of these was previously restated at every surface that renders the resource — the list | | 29 | + * page, the detail page, and (for knowledge bases) the document and chunk views — which is how | | 30 | + * the same trail ends up with a different label or icon depending on which page you reached it | | 31 | + * from. Adding a foldered resource is an entry here plus its pages, never a fifth literal. | | 32 | + */ | | 33 | +export const FOLDERED_RESOURCE_HEADERS: Record< | | 34 | + FolderedHeaderResourceType, | | 35 | + FolderedResourceHeaderMeta | | 36 | +> = { | | 37 | + file: { rootLabel: 'Files', rootIcon: FileIcon, listSegment: 'files' }, | | 38 | + knowledge_base: { rootLabel: 'Knowledge Base', rootIcon: Database, listSegment: 'knowledge' }, | | 39 | + table: { rootLabel: 'Tables', rootIcon: TableIcon, listSegment: 'tables' }, | | 40 | +} | | 41 | + | | 42 | +/** | | 43 | + * Href of a foldered resource's list page, opened at `folderId` or at its workspace root. | | 44 | + * | | 45 | + * Detail pages navigate to a different route, so their breadcrumb folder crumbs cannot use the | | 46 | + * nuqs setter — it only mutates the query of the current path. | | 47 | + */ | | 48 | +export function folderedResourceListHref( | | 49 | + resourceType: FolderedHeaderResourceType, | | 50 | + workspaceId: string, | | 51 | + folderId: string | null | | 52 | +): string { | | 53 | + const { listSegment } = FOLDERED_RESOURCE_HEADERS[resourceType] | | 54 | + return folderListHref(`/workspace/${workspaceId}/${listSegment}`, folderId) | | 55 | +} |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +93-1Lines changed: 93 additions & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 2 | 2 | * @vitest-environment node | 3 | 3 | */ | 4 | 4 | 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' | 6 | 10 | import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming' | 7 | 11 | import { | 8 | 12 | folderRowId, | @@ -238,4 +242,92 @@ describe('folderBreadcrumbItems', () => { | 238 | 242 | expect(items[2].editing).toBe(currentFolderEditing) | 239 | 243 | expect(items[2].dropdownItems).toBe(currentFolderActions) | 240 | 244 | }) | | 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 | + }) | 241 | 333 | }) |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +9-2Lines changed: 9 additions & 2 deletions | Original file line number | Diff line number | Diff line change |
|---|
| 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' | 3 | 3 | export { FolderContextMenu } from './folder-context-menu' | 4 | 4 | export { nextUntitledFolderName } from './folder-naming' | 5 | 5 | export type { FolderRowOptions } from './folder-row' | 6 | 6 | export { folderRow } from './folder-row' | 7 | 7 | export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' | 8 | 8 | 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' | 9 | 14 | export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' | 10 | 15 | export { | 11 | 16 | buildDescendantIndex, | @@ -18,6 +23,8 @@ export { | 18 | 23 | export type { SortableResource } from './resource-sort' | 19 | 24 | export { sortResources } from './resource-sort' | 20 | 25 | export { folderNavParsers, folderNavUrlKeys } from './search-params' | | 26 | +export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors' | | 27 | +export { useFolderAncestors } from './use-folder-ancestors' | 21 | 28 | export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' | 22 | 29 | export { useFolderNavigation } from './use-folder-navigation' | 23 | 30 | export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' | |
Collapse file Copy file name to clipboardExpand all lines: apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts +10Lines changed: 10 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
@@ -23,3 +23,13 @@ export const folderNavUrlKeys = { | 23 | 23 | history: 'push', | 24 | 24 | clearOnDefault: true, | 25 | 25 | } 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 | +} |
|