Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,114 @@
import type { ElementType } from 'react'
import { folderAncestorChain } from '@/lib/folders/tree'
import type {
BreadcrumbEditing,
BreadcrumbItem,
DropdownOption,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import type { WorkflowFolder } from '@/stores/folders/types'

export interface FolderBreadcrumbItemsOptions {
/** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */
/**
* Structural rather than `WorkflowFolder` so the Files tree — same `folder` table, own routes
* and row type (see `servedFolderResourceTypeSchema` in `@/lib/api/contracts/folders`) — shares
* this code path instead of forking it.
*/
export interface BreadcrumbFolder {
id: string
name: string
parentId: string | null
}

const EMPTY_CHAIN: never[] = []

/**
* Root-first ancestor chain, that folder last, or empty when it does not reach the root —
* where {@link folderAncestorChain} would hand back the part it walked.
*
* A partial path is not a shorter path, it is a wrong one: it claims the deepest folder it
* resolved sits at the workspace root. Falling back to the root title is the honest render.
* Completeness is `chain[0].parentId === null`, which also rejects a cycle. Callers must pass
* the complete tree — see `FolderAncestors.foldersResolved`.
*/
export function breadcrumbFolderChain<T extends BreadcrumbFolder>(
folderId: string | null | undefined,
folderById: ReadonlyMap<string, T>
): T[] {
const chain = folderAncestorChain(folderId, (id) => folderById.get(id))
return chain.length === 0 || chain[0].parentId === null ? chain : EMPTY_CHAIN
}

interface FolderBreadcrumbItemsBase {
/** Root crumb label — the page's own name ("Knowledge bases", "Tables"). */
rootLabel: string
rootIcon?: ElementType
/** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */
breadcrumbs: WorkflowFolder[]
/** Root-first ancestor chain, from {@link folderAncestorChain}. */
breadcrumbs: BreadcrumbFolder[]
/** Called with the folder to open, or `null` for the workspace root. */
onNavigate: (folderId: string | null) => void
}

/** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */
interface FolderListBreadcrumbOptions extends FolderBreadcrumbItemsBase {
/** Menu attached to the open folder's crumb (rename, delete, …). */
currentFolderActions?: DropdownOption[]
/** Inline rename bound to the open folder's crumb. */
currentFolderEditing?: BreadcrumbEditing
trailing?: never
}

/** A detail page: the open resource is where you are, so every folder crumb navigates. */
interface FolderDetailBreadcrumbOptions extends FolderBreadcrumbItemsBase {
/**
* Crumbs appended after the folder trail — the resource open on a detail page, plus
* anything nested under it (a knowledge base's document, that document's chunk).
*/
trailing: BreadcrumbItem[]
currentFolderActions?: never
currentFolderEditing?: never
}

/**
* Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header`
* renders.
* The two modes are disjoint by construction rather than by convention: an open-folder rename
* or menu acts on the folder you are inside, which on a detail page you are not. Expressed as
* a union so passing both is a compile error instead of a handler that silently never fires.
*/
export type FolderBreadcrumbItemsOptions =
| FolderListBreadcrumbOptions
| FolderDetailBreadcrumbOptions

const NO_TRAILING_CRUMBS: BreadcrumbItem[] = []

/**
* Builds the `BreadcrumbItem[]` for a list page (`Tables / Reports`) or a detail page
* (`Tables / Reports / Q3`).
*
* A plain builder rather than a component: `Resource.Header` already owns every piece of
* breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow
* tooltips, and the rule that a single-element trail renders as a plain page title. A
* sibling crumb component would have to fork all of it, which is exactly what this shared
* directory exists to prevent.
*
* The trail always starts with the root crumb, so at the workspace root the result has
* length 1 and the header renders the page title unchanged.
* tooltips, and the rule that a single-element trail renders as a plain page title. A sibling
* crumb component would have to fork all of it, which is what this directory exists to prevent.
*/
export function folderBreadcrumbItems({
rootLabel,
rootIcon,
breadcrumbs,
onNavigate,
currentFolderActions,
currentFolderEditing,
}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] {
export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): BreadcrumbItem[] {
const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options
const trailing = options.trailing ?? NO_TRAILING_CRUMBS

const items: BreadcrumbItem[] = [
{ label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) },
]

breadcrumbs.forEach((folder, index) => {
const isCurrent = index === breadcrumbs.length - 1
/** The open folder is where you already are, so its crumb is not a navigation target. */
/** Where you already are — and on a detail page that is a trailing crumb, not a folder. */
const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1
items.push({
label: folder.name,
onClick: isCurrent ? undefined : () => onNavigate(folder.id),
dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined,
editing: isCurrent ? currentFolderEditing : undefined,
onClick: isOpenFolder ? undefined : () => onNavigate(folder.id),
dropdownItems:
isOpenFolder && options.currentFolderActions?.length
? options.currentFolderActions
: undefined,
editing: isOpenFolder ? options.currentFolderEditing : undefined,
})
})

items.push(...trailing)

return items
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
import type { ElementType } from 'react'
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
import type { FolderResourceType } from '@/lib/api/contracts/folders'
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'

/**
* The foldered resources that render a `Resource.Header` breadcrumb trail. A subset of
* {@link FolderResourceType}: workflows are foldered too, but they live in the editor sidebar
* rather than on a list page with a header.
*/
export type FolderedHeaderResourceType = Extract<
FolderResourceType,
'file' | 'knowledge_base' | 'table'
>

export interface FolderedResourceHeaderMeta {
/** Root crumb label, and the page title at the workspace root. */
rootLabel: string
/** Icon on the root crumb, which is also what opens the header's "Path" popover. */
rootIcon: ElementType
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
listSegment: string
}

/**
* The per-resource facts a foldered header needs, in one place.
*
* Each was previously restated at every surface rendering that resource — list page, detail
* page, and for knowledge bases the document and chunk views — which is how one trail ends up
* labelled differently depending on which page you reached it from.
*/
export const FOLDERED_RESOURCE_HEADERS: Record<
FolderedHeaderResourceType,
FolderedResourceHeaderMeta
> = {
file: { rootLabel: 'Files', rootIcon: FileIcon, listSegment: 'files' },
knowledge_base: { rootLabel: 'Knowledge bases', rootIcon: Database, listSegment: 'knowledge' },
table: { rootLabel: 'Tables', rootIcon: TableIcon, listSegment: 'tables' },
}

/**
* Href of a foldered resource's list page, opened at `folderId` or at its workspace root.
*
* Detail pages navigate to a different route, so their breadcrumb folder crumbs cannot use the
* nuqs setter — it only mutates the query of the current path.
*/
export function folderedResourceListHref(
resourceType: FolderedHeaderResourceType,
workspaceId: string,
folderId: string | null
): string {
const { listSegment } = FOLDERED_RESOURCE_HEADERS[resourceType]
return folderListHref(`/workspace/${workspaceId}/${listSegment}`, folderId)
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,11 @@
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
import { folderBreadcrumbItems } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
import { folderAncestorChain } from '@/lib/folders/tree'
import {
breadcrumbFolderChain,
folderBreadcrumbItems,
} from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming'
import {
folderRowId,
Expand DownExpand Up@@ -238,4 +242,92 @@ describe('folderBreadcrumbItems', () => {
expect(items[2].editing).toBe(currentFolderEditing)
expect(items[2].dropdownItems).toBe(currentFolderActions)
})

it('appends the trailing crumbs of a detail page after the folder chain', () => {
const items = folderBreadcrumbItems({
rootLabel: 'Tables',
breadcrumbs: [makeFolder('root', null, { name: 'Alpha' })],
onNavigate: vi.fn(),
trailing: [{ label: 'Q3' }],
})
expect(items.map((item) => item.label)).toEqual(['Tables', 'Alpha', 'Q3'])
})

it('makes every folder crumb navigable once a trailing crumb is where you are', () => {
const onNavigate = vi.fn()
const items = folderBreadcrumbItems({
rootLabel: 'Tables',
breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')],
onNavigate,
trailing: [{ label: 'Q3' }],
})

items[2].onClick?.()
expect(onNavigate).toHaveBeenCalledWith('leaf')
})

it('leaves the deepest folder crumb plain on a detail page — the rename and menu are list-only', () => {
const items = folderBreadcrumbItems({
rootLabel: 'Tables',
breadcrumbs: [makeFolder('leaf')],
onNavigate: vi.fn(),
trailing: [{ label: 'Q3' }],
})

expect(items[1].dropdownItems).toBeUndefined()
expect(items[1].editing).toBeUndefined()
})
})

describe('breadcrumbFolderChain', () => {
function mapOf(...folders: WorkflowFolder[]) {
return new Map(folders.map((folder) => [folder.id, folder]))
}

it('returns nothing at the workspace root', () => {
expect(breadcrumbFolderChain(null, mapOf(makeFolder('a')))).toEqual([])
expect(breadcrumbFolderChain(undefined, mapOf(makeFolder('a')))).toEqual([])
})

it('walks parentId up to the root and returns the chain root-first', () => {
const chain = breadcrumbFolderChain(
'leaf',
mapOf(makeFolder('root'), makeFolder('mid', 'root'), makeFolder('leaf', 'mid'))
)
expect(chain.map((folder) => folder.id)).toEqual(['root', 'mid', 'leaf'])
})

it('collapses the whole chain when an ancestor does not resolve, rather than skipping a level', () => {
const chain = breadcrumbFolderChain('leaf', mapOf(makeFolder('leaf', 'gone')))
expect(chain).toEqual([])
})

it('collapses a parent cycle the DB permits between constraint checks, rather than hanging', () => {
const chain = breadcrumbFolderChain('a', mapOf(makeFolder('a', 'b'), makeFolder('b', 'a')))
expect(chain).toEqual([])
})

it('collapses a chain the folder map is still too incomplete to root', () => {
const chain = breadcrumbFolderChain(
'leaf',
mapOf(makeFolder('mid', 'root'), makeFolder('leaf', 'mid'))
)
expect(chain).toEqual([])
})
})

describe('folderAncestorChain', () => {
it('keeps the part it walked when a link does not resolve — the breadcrumb rule is a wrapper', () => {
const folders: Record<string, WorkflowFolder> = { leaf: makeFolder('leaf', 'gone') }
const chain = folderAncestorChain('leaf', (id) => folders[id])
expect(chain.map((folder) => folder.id)).toEqual(['leaf'])
})

it('stops on a cycle instead of looping forever', () => {
const folders: Record<string, WorkflowFolder> = {
a: makeFolder('a', 'b'),
b: makeFolder('b', 'a'),
}
expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a'])
})
})
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
export type { FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
export { folderBreadcrumbItems } from './folder-breadcrumbs'
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
export { FolderContextMenu } from './folder-context-menu'
export { nextUntitledFolderName } from './folder-naming'
export type { FolderRowOptions } from './folder-row'
export { folderRow } from './folder-row'
export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id'
export { folderRowId, parseFolderedRowId } from './folder-row-id'
export type {
FolderedHeaderResourceType,
FolderedResourceHeaderMeta,
} from './foldered-resources'
export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources'
export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options'
export {
buildDescendantIndex,
Expand All@@ -18,6 +23,8 @@ export {
export type { SortableResource } from './resource-sort'
export { sortResources } from './resource-sort'
export { folderNavParsers, folderNavUrlKeys } from './search-params'
export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors'
export { useFolderAncestors } from './use-folder-ancestors'
export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation'
export { useFolderNavigation } from './use-folder-navigation'
export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop'
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,3 +23,13 @@ export const folderNavUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const

/**
* Href of a foldered list page opened at `folderId`, or of its workspace root when `null`.
*
* Lives here so a hand-built link cannot drift from {@link folderNavParsers} on the wire key.
*/
export function folderListHref(listPath: string, folderId: string | null): string {
if (!folderId) return listPath
return `${listPath}?${new URLSearchParams({ folderId })}`
}
Loading
Loading