mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import { breadcrumbFolderChain } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
export interface UseFolderAncestorsOptions {
|
||||
resourceType: ServedFolderResourceType
|
||||
workspaceId?: string
|
||||
/**
|
||||
* The folder to build the chain for — the open folder on a list page, or the resource's
|
||||
* own `folderId` on a detail page.
|
||||
*/
|
||||
folderId: string | null | undefined
|
||||
/**
|
||||
* Set `false` on a surface that renders no breadcrumb trail — the embedded table view — so
|
||||
* it does not fetch a folder tree it will never show.
|
||||
*/
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface FolderAncestors {
|
||||
/**
|
||||
* Root-first ancestor chain of `folderId`, that folder last. Empty at the workspace root,
|
||||
* and empty while the folder list is still loading or when the id no longer resolves (a
|
||||
* deleted folder, a stale bookmark, a resource moved by someone else) — callers render the
|
||||
* root trail rather than a path that skips a level.
|
||||
*/
|
||||
ancestors: WorkflowFolder[]
|
||||
/** Every active folder in this resource's tree, as returned by the folders API. */
|
||||
folders: WorkflowFolder[]
|
||||
folderById: Map<string, WorkflowFolder>
|
||||
/**
|
||||
* Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace.
|
||||
*
|
||||
* Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a
|
||||
* disabled query (no `workspaceId`), false for an errored one, and — because `useFolders`
|
||||
* sets `keepPreviousData` — false while the previous workspace's folders are still on screen
|
||||
* during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it
|
||||
* as an orphan" off `isLoading` would dump every foldered row at the root in all three.
|
||||
*/
|
||||
foldersResolved: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
/**
|
||||
* The folder tree for one resource type, plus the ancestor chain of a single folder in it.
|
||||
*
|
||||
* Shared by {@link useFolderNavigation} (which passes the URL's open folder) and by detail
|
||||
* pages (which pass the open resource's own `folderId`), so a table's header trail and the
|
||||
* table list's header trail are built from the same tree by the same walk.
|
||||
*/
|
||||
export function useFolderAncestors({
|
||||
resourceType,
|
||||
workspaceId,
|
||||
folderId,
|
||||
enabled,
|
||||
}: UseFolderAncestorsOptions): FolderAncestors {
|
||||
const {
|
||||
data: folders = EMPTY_FOLDERS,
|
||||
isSuccess,
|
||||
isPlaceholderData,
|
||||
} = useFolders(workspaceId, { resourceType, enabled })
|
||||
|
||||
const folderById = useMemo(() => {
|
||||
const byId = new Map<string, WorkflowFolder>()
|
||||
for (const folder of folders) byId.set(folder.id, folder)
|
||||
return byId
|
||||
}, [folders])
|
||||
|
||||
const ancestors = useMemo(
|
||||
() => breadcrumbFolderChain(folderId, folderById),
|
||||
[folderId, folderById]
|
||||
)
|
||||
|
||||
return {
|
||||
ancestors,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved: isSuccess && !isPlaceholderData,
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import {
|
||||
folderNavParsers,
|
||||
folderNavUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders/search-params'
|
||||
import { useFolders } from '@/hooks/queries/folders'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
import {
|
||||
type FolderAncestors,
|
||||
useFolderAncestors,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders/use-folder-ancestors'
|
||||
|
||||
export interface UseFolderNavigationOptions {
|
||||
resourceType: ServedFolderResourceType
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
export interface FolderNavigation {
|
||||
export interface FolderNavigation extends FolderAncestors {
|
||||
/** The open folder, or `null` at the workspace root. */
|
||||
currentFolderId: string | null
|
||||
setCurrentFolderId: (folderId: string | null) => void
|
||||
/**
|
||||
* Root-first ancestor chain of the open folder, the open folder last. Empty at the root,
|
||||
* and empty while the folder list is still loading or when the id no longer resolves (a
|
||||
* deleted folder or a stale bookmark) — callers fall back to the root listing rather than
|
||||
* rendering a broken trail.
|
||||
*/
|
||||
breadcrumbs: WorkflowFolder[]
|
||||
/** Every active folder in this resource's tree, as returned by the folders API. */
|
||||
folders: WorkflowFolder[]
|
||||
folderById: Map<string, WorkflowFolder>
|
||||
/**
|
||||
* Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace.
|
||||
*
|
||||
* Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a
|
||||
* disabled query (no `workspaceId`), false for an errored one, and — because `useFolders`
|
||||
* sets `keepPreviousData` — false while the previous workspace's folders are still on screen
|
||||
* during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it
|
||||
* as an orphan" off `isLoading` would dump every foldered row at the root in all three.
|
||||
*/
|
||||
foldersResolved: boolean
|
||||
}
|
||||
|
||||
const EMPTY_FOLDERS: WorkflowFolder[] = []
|
||||
|
||||
/**
|
||||
* URL-backed folder navigation for a foldered resource list. Deliberately
|
||||
* resourceType-agnostic — the Workflows, Files, Knowledge, and Tables trees are separate
|
||||
@@ -61,21 +41,12 @@ export function useFolderNavigation({
|
||||
folderNavUrlKeys
|
||||
)
|
||||
|
||||
const {
|
||||
data: folders = EMPTY_FOLDERS,
|
||||
isSuccess,
|
||||
isPlaceholderData,
|
||||
} = useFolders(workspaceId, { resourceType })
|
||||
|
||||
/**
|
||||
* The folder list is only trustworthy enough to evict a `folderId` when the query has
|
||||
* actually succeeded for THIS workspace. `isLoading` alone is not that signal: it is false
|
||||
* for a disabled query (no `workspaceId`), false for an errored one, and — because
|
||||
* `useFolders` sets `keepPreviousData` — false while showing the previous workspace's
|
||||
* folders during a workspace switch. In all three the list is empty or stale, and healing
|
||||
* off it would throw away a perfectly good folder.
|
||||
*/
|
||||
const foldersResolved = isSuccess && !isPlaceholderData
|
||||
const ancestry = useFolderAncestors({
|
||||
resourceType,
|
||||
workspaceId,
|
||||
folderId: currentFolderId,
|
||||
})
|
||||
const { folderById, foldersResolved } = ancestry
|
||||
|
||||
const setCurrentFolderId = useCallback(
|
||||
(folderId: string | null) => {
|
||||
@@ -84,12 +55,6 @@ export function useFolderNavigation({
|
||||
[setFolderParams]
|
||||
)
|
||||
|
||||
const folderById = useMemo(() => {
|
||||
const byId = new Map<string, WorkflowFolder>()
|
||||
for (const folder of folders) byId.set(folder.id, folder)
|
||||
return byId
|
||||
}, [folders])
|
||||
|
||||
/**
|
||||
* Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a
|
||||
* link from someone whose workspace it was not.
|
||||
@@ -114,36 +79,5 @@ export function useFolderNavigation({
|
||||
void setFolderParams({ folderId: null }, { history: 'replace' })
|
||||
}, [foldersResolved, currentFolderId, folderById, setFolderParams])
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
if (!currentFolderId) return EMPTY_FOLDERS
|
||||
|
||||
/**
|
||||
* Walks up via `parentId` rather than splitting a materialized path — the generic
|
||||
* folder table stores no path — and guards against a cycle, which the DB permits
|
||||
* between constraint checks. An unresolvable link collapses the whole trail so the
|
||||
* header falls back to the root title instead of rendering a partial path.
|
||||
*/
|
||||
const chain: WorkflowFolder[] = []
|
||||
const seen = new Set<string>()
|
||||
let cursor: string | null = currentFolderId
|
||||
|
||||
while (cursor && !seen.has(cursor)) {
|
||||
seen.add(cursor)
|
||||
const folder: WorkflowFolder | undefined = folderById.get(cursor)
|
||||
if (!folder) return EMPTY_FOLDERS
|
||||
chain.unshift(folder)
|
||||
cursor = folder.parentId
|
||||
}
|
||||
|
||||
return chain
|
||||
}, [currentFolderId, folderById])
|
||||
|
||||
return {
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
breadcrumbs,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
}
|
||||
return { ...ancestry, currentFolderId, setCurrentFolderId }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Columns2,
|
||||
type ComboboxOption,
|
||||
Eye,
|
||||
File as FilesIcon,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Loader,
|
||||
@@ -67,6 +66,10 @@ import type {
|
||||
SortableResource,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import {
|
||||
breadcrumbFolderChain,
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
folderBreadcrumbItems,
|
||||
folderedResourceListHref,
|
||||
parseMoveOptionValue,
|
||||
ROOT_MOVE_OPTION_VALUE,
|
||||
sortResources,
|
||||
@@ -136,6 +139,8 @@ type FileListEntry =
|
||||
|
||||
const logger = createLogger('Files')
|
||||
|
||||
const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file
|
||||
|
||||
const FOLDER_ICON = <Folder className='size-[14px]' />
|
||||
|
||||
/** Folders' value in the `type` column — also their sort key when that column is active. */
|
||||
@@ -432,7 +437,6 @@ export function Files() {
|
||||
) : null
|
||||
|
||||
const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders])
|
||||
const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null
|
||||
|
||||
const folderSizeMap = useMemo(() => {
|
||||
const directSize = new Map<string, number>()
|
||||
@@ -475,7 +479,6 @@ export function Files() {
|
||||
for (const folder of folders) getTotal(folder.id)
|
||||
return totalSize
|
||||
}, [files, folders])
|
||||
const currentFolderPath = currentFolder?.path ?? null
|
||||
|
||||
const visibleFolders = useMemo(() => {
|
||||
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
|
||||
@@ -1104,11 +1107,7 @@ export function Files() {
|
||||
if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) {
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
router.push(
|
||||
currentFolderId
|
||||
? `/workspace/${workspaceId}/files?folderId=${currentFolderId}`
|
||||
: `/workspace/${workspaceId}/files`
|
||||
)
|
||||
router.push(folderedResourceListHref('file', workspaceId, currentFolderId))
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to delete file:', err)
|
||||
@@ -1226,55 +1225,40 @@ export function Files() {
|
||||
await downloadArchive({ fileIds: selectedFileIds, folderIds: selectedFolderIds })
|
||||
}, [selectedFileIds, selectedFolderIds, files, handleDownload, downloadArchive, workspaceId])
|
||||
|
||||
const fileDetailBreadcrumbs = useMemo(() => {
|
||||
const fileDetailBreadcrumbs = useMemo((): BreadcrumbItem[] => {
|
||||
if (!selectedFile) return []
|
||||
|
||||
const folderBreadcrumbs: BreadcrumbItem[] = []
|
||||
const visitedFolderIds = new Set<string>()
|
||||
let folderId = selectedFile.folderId
|
||||
|
||||
while (folderId && !visitedFolderIds.has(folderId)) {
|
||||
visitedFolderIds.add(folderId)
|
||||
const folder = folderById.get(folderId)
|
||||
if (!folder) break
|
||||
|
||||
folderBreadcrumbs.unshift({
|
||||
label: folder.name,
|
||||
onClick: () =>
|
||||
handleNavigateFromFileDetail(`/workspace/${workspaceId}/files?folderId=${folder.id}`),
|
||||
})
|
||||
folderId = folder.parentId
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Files',
|
||||
onClick: () => handleNavigateFromFileDetail(`/workspace/${workspaceId}/files`),
|
||||
},
|
||||
...folderBreadcrumbs,
|
||||
{
|
||||
label: selectedFile.name,
|
||||
editing: headerRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: headerRename.editValue,
|
||||
onChange: headerRename.setEditValue,
|
||||
onSubmit: headerRename.submitRename,
|
||||
onCancel: headerRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
{ label: 'Download', icon: Download, onClick: handleDownloadSelected },
|
||||
...(canEdit
|
||||
? [
|
||||
{ label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename },
|
||||
{ label: 'Share', icon: Send, onClick: handleShareSelected },
|
||||
{ label: 'Delete', icon: Trash, onClick: handleDeleteSelected },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
return folderBreadcrumbItems({
|
||||
rootLabel: FILES_HEADER.rootLabel,
|
||||
rootIcon: FILES_HEADER.rootIcon,
|
||||
breadcrumbs: breadcrumbFolderChain(selectedFile.folderId, folderById),
|
||||
onNavigate: (folderId) =>
|
||||
handleNavigateFromFileDetail(folderedResourceListHref('file', workspaceId, folderId)),
|
||||
trailing: [
|
||||
{
|
||||
label: selectedFile.name,
|
||||
editing: headerRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: headerRename.editValue,
|
||||
onChange: headerRename.setEditValue,
|
||||
onSubmit: headerRename.submitRename,
|
||||
onCancel: headerRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
{ label: 'Download', icon: Download, onClick: handleDownloadSelected },
|
||||
...(canEdit
|
||||
? [
|
||||
{ label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename },
|
||||
{ label: 'Share', icon: Send, onClick: handleShareSelected },
|
||||
{ label: 'Delete', icon: Trash, onClick: handleDeleteSelected },
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}, [
|
||||
selectedFile,
|
||||
folderById,
|
||||
@@ -1295,12 +1279,9 @@ export function Files() {
|
||||
setIsDirty(false)
|
||||
setSaveStatus('idle')
|
||||
setPreviewMode('editor')
|
||||
const folderId = selectedFileRef.current?.folderId
|
||||
const folderId = selectedFileRef.current?.folderId ?? null
|
||||
const targetUrl =
|
||||
pendingFileNavigationUrlRef.current ??
|
||||
(folderId
|
||||
? `/workspace/${workspaceId}/files?folderId=${folderId}`
|
||||
: `/workspace/${workspaceId}/files`)
|
||||
pendingFileNavigationUrlRef.current ?? folderedResourceListHref('file', workspaceId, folderId)
|
||||
pendingFileNavigationUrlRef.current = null
|
||||
router.push(targetUrl)
|
||||
}
|
||||
@@ -1750,75 +1731,85 @@ export function Files() {
|
||||
]
|
||||
)
|
||||
|
||||
const handleNavigateToFiles = useCallback(() => {
|
||||
void setFilesParams({ folderId: null, new: null })
|
||||
}, [setFilesParams])
|
||||
|
||||
const loadingBreadcrumbs = useMemo(
|
||||
(): BreadcrumbItem[] => [
|
||||
{ label: 'Files', onClick: handleNavigateToFiles },
|
||||
{ label: '…', terminal: true },
|
||||
],
|
||||
[handleNavigateToFiles]
|
||||
const handleNavigateToListFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
void setFilesParams({ folderId, new: null })
|
||||
},
|
||||
[setFilesParams]
|
||||
)
|
||||
|
||||
const breadcrumbRenameRef = useRef(breadcrumbRename)
|
||||
breadcrumbRenameRef.current = breadcrumbRename
|
||||
const listFolderChain = useMemo(
|
||||
() => breadcrumbFolderChain(currentFolderId, folderById),
|
||||
[currentFolderId, folderById]
|
||||
)
|
||||
|
||||
const listBreadcrumbs = useMemo(() => {
|
||||
const breadcrumbs: BreadcrumbItem[] = [{ label: 'Files', onClick: handleNavigateToFiles }]
|
||||
if (!currentFolderPath) return breadcrumbs
|
||||
/**
|
||||
* The trail while a file's content loads. Holds the URL's open folder so arriving from a
|
||||
* list page inside `A/B` doesn't collapse to `Files / …` and jump back out once the file
|
||||
* lands; a cold deep-link has no `?folderId=` and no loaded file, so it starts at the root.
|
||||
*
|
||||
* Renders on the file *detail* route, so its crumbs navigate through the router like
|
||||
* {@link fileDetailBreadcrumbs} — a nuqs write would only requery this file's own URL.
|
||||
*/
|
||||
const loadingBreadcrumbs = useMemo(
|
||||
(): BreadcrumbItem[] =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: FILES_HEADER.rootLabel,
|
||||
rootIcon: FILES_HEADER.rootIcon,
|
||||
breadcrumbs: listFolderChain,
|
||||
onNavigate: (folderId) =>
|
||||
handleNavigateFromFileDetail(folderedResourceListHref('file', workspaceId, folderId)),
|
||||
trailing: [{ label: '…', terminal: true }],
|
||||
}),
|
||||
[listFolderChain, handleNavigateFromFileDetail, workspaceId]
|
||||
)
|
||||
|
||||
const segments = currentFolderPath.split('/')
|
||||
let parentId: string | null = null
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]
|
||||
const folder = folders.find(
|
||||
(item) => item.name === segment && (item.parentId ?? null) === parentId
|
||||
)
|
||||
if (!folder) continue
|
||||
const isCurrentFolder = folder.id === currentFolderId
|
||||
breadcrumbs.push({
|
||||
label: folder.name,
|
||||
onClick: isCurrentFolder
|
||||
? undefined
|
||||
: () => void setFilesParams({ folderId: folder.id, new: null }),
|
||||
editing:
|
||||
isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id
|
||||
const openListFolder = currentFolderId ? folderById.get(currentFolderId) : undefined
|
||||
|
||||
const listBreadcrumbs = useMemo(
|
||||
(): BreadcrumbItem[] =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: FILES_HEADER.rootLabel,
|
||||
rootIcon: FILES_HEADER.rootIcon,
|
||||
breadcrumbs: listFolderChain,
|
||||
onNavigate: handleNavigateToListFolder,
|
||||
currentFolderEditing:
|
||||
openListFolder && breadcrumbRename.editingId === openListFolder.id
|
||||
? {
|
||||
isEditing: true,
|
||||
value: breadcrumbRenameRef.current.editValue,
|
||||
onChange: breadcrumbRenameRef.current.setEditValue,
|
||||
onSubmit: breadcrumbRenameRef.current.submitRename,
|
||||
onCancel: breadcrumbRenameRef.current.cancelRename,
|
||||
value: breadcrumbRename.editValue,
|
||||
onChange: breadcrumbRename.setEditValue,
|
||||
onSubmit: breadcrumbRename.submitRename,
|
||||
onCancel: breadcrumbRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems:
|
||||
isCurrentFolder && (canEdit || userPermissions.isLoading)
|
||||
currentFolderActions:
|
||||
openListFolder && (canEdit || userPermissions.isLoading)
|
||||
? [
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: Pencil,
|
||||
disabled: !canEdit,
|
||||
onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name),
|
||||
onClick: () =>
|
||||
breadcrumbRename.startRename(openListFolder.id, openListFolder.name),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
})
|
||||
parentId = folder.id
|
||||
}
|
||||
return breadcrumbs
|
||||
}, [
|
||||
currentFolderPath,
|
||||
currentFolderId,
|
||||
folders,
|
||||
handleNavigateToFiles,
|
||||
setFilesParams,
|
||||
canEdit,
|
||||
userPermissions.isLoading,
|
||||
breadcrumbRename.editingId,
|
||||
breadcrumbRename.editValue,
|
||||
])
|
||||
}),
|
||||
[
|
||||
listFolderChain,
|
||||
openListFolder,
|
||||
handleNavigateToListFolder,
|
||||
canEdit,
|
||||
userPermissions.isLoading,
|
||||
breadcrumbRename.editingId,
|
||||
breadcrumbRename.editValue,
|
||||
breadcrumbRename.setEditValue,
|
||||
breadcrumbRename.submitRename,
|
||||
breadcrumbRename.cancelRename,
|
||||
breadcrumbRename.startRename,
|
||||
]
|
||||
)
|
||||
|
||||
const memberOptions: ComboboxOption[] = useMemo(
|
||||
() =>
|
||||
@@ -2035,7 +2026,7 @@ export function Files() {
|
||||
if (fileIdFromRoute && !selectedFile && isLoading) {
|
||||
return (
|
||||
<Resource>
|
||||
<Resource.Header icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<Resource.Header icon={FILES_HEADER.rootIcon} breadcrumbs={loadingBreadcrumbs} />
|
||||
<div className='flex flex-1 items-center justify-center bg-[var(--bg)]'>
|
||||
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
|
||||
</div>
|
||||
@@ -2052,7 +2043,7 @@ export function Files() {
|
||||
<FileDocRoomProvider>
|
||||
<Resource>
|
||||
<Resource.Header
|
||||
icon={FilesIcon}
|
||||
icon={FILES_HEADER.rootIcon}
|
||||
breadcrumbs={fileDetailBreadcrumbs}
|
||||
actions={fileActions}
|
||||
aside={<FileDocAvatars />}
|
||||
@@ -2119,8 +2110,8 @@ export function Files() {
|
||||
>
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header
|
||||
icon={FilesIcon}
|
||||
title='Files'
|
||||
icon={FILES_HEADER.rootIcon}
|
||||
title={FILES_HEADER.rootLabel}
|
||||
breadcrumbs={listBreadcrumbs}
|
||||
actions={headerActionsConfig}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,12 @@ import type {
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
folderBreadcrumbItems,
|
||||
folderedResourceListHref,
|
||||
useFolderAncestors,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import {
|
||||
ChunkContextMenu,
|
||||
ChunkEditor,
|
||||
@@ -129,7 +135,7 @@ export function Document({
|
||||
knowledgeBaseName,
|
||||
documentName,
|
||||
}: DocumentProps) {
|
||||
const { workspaceId } = useParams()
|
||||
const workspaceId = useParams().workspaceId as string
|
||||
const router = useRouter()
|
||||
const [
|
||||
{
|
||||
@@ -145,6 +151,13 @@ export function Document({
|
||||
const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId)
|
||||
const { document: documentData, error: documentError } = useDocument(knowledgeBaseId, documentId)
|
||||
|
||||
/** The base's folder trail, so this route's header matches the base's and the list's. */
|
||||
const { ancestors: folderChain } = useFolderAncestors({
|
||||
resourceType: 'knowledge_base',
|
||||
workspaceId,
|
||||
folderId: knowledgeBase?.folderId,
|
||||
})
|
||||
|
||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
||||
|
||||
/**
|
||||
@@ -490,13 +503,65 @@ export function Document({
|
||||
[guardDirtyAction, navigateToChunk]
|
||||
)
|
||||
|
||||
const handleNavToKB = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/knowledge`)
|
||||
}, [router, workspaceId])
|
||||
/**
|
||||
* Confirms before a crumb navigates away from an unsaved chunk — a route change unmounts the
|
||||
* editor, so the edit is gone with no way back.
|
||||
*
|
||||
* Gated on the editor being open rather than on `isDirty` alone: `UnsavedChangesModal` mounts
|
||||
* only alongside the editor, but `isDirty` outlives a URL-driven unmount (browser Back off an
|
||||
* edited chunk), where guarding would raise a modal nothing renders and deaden the crumb.
|
||||
*/
|
||||
const guardRouteChange = useCallback(
|
||||
(navigate: () => void) => {
|
||||
if (isCreatingNewChunk || selectedChunkId) guardDirtyAction(navigate)
|
||||
else navigate()
|
||||
},
|
||||
[isCreatingNewChunk, selectedChunkId, guardDirtyAction]
|
||||
)
|
||||
|
||||
const handleNavToFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
guardRouteChange(() =>
|
||||
router.push(folderedResourceListHref('knowledge_base', workspaceId, folderId))
|
||||
)
|
||||
},
|
||||
[guardRouteChange, router, workspaceId]
|
||||
)
|
||||
|
||||
const handleNavToKBDetail = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||
}, [router, workspaceId, knowledgeBaseId])
|
||||
guardRouteChange(() => router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`))
|
||||
}, [guardRouteChange, router, workspaceId, knowledgeBaseId])
|
||||
|
||||
/**
|
||||
* `Knowledge Base / …the base's folders / <base> / <last>`. Every view on this route is that
|
||||
* trail with a different last crumb — the document, a chunk, an error, a loading placeholder
|
||||
* — so it is built once here rather than restated per view.
|
||||
*/
|
||||
const documentTrail = useCallback(
|
||||
(last: BreadcrumbItem, onDocumentClick?: () => void): BreadcrumbItem[] =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel,
|
||||
rootIcon: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon,
|
||||
breadcrumbs: folderChain,
|
||||
onNavigate: handleNavToFolder,
|
||||
trailing: [
|
||||
{ label: knowledgeBaseCrumbLabel, icon: Database, onClick: handleNavToKBDetail },
|
||||
/** Omitted when the document IS the last crumb — you are already on it. */
|
||||
...(onDocumentClick
|
||||
? [{ label: documentCrumbLabel, icon: DocumentIcon, onClick: onDocumentClick }]
|
||||
: []),
|
||||
last,
|
||||
],
|
||||
}),
|
||||
[
|
||||
folderChain,
|
||||
handleNavToFolder,
|
||||
handleNavToKBDetail,
|
||||
knowledgeBaseCrumbLabel,
|
||||
documentCrumbLabel,
|
||||
DocumentIcon,
|
||||
]
|
||||
)
|
||||
|
||||
const handleStartDocRename = useCallback(() => {
|
||||
docRename.startRename(documentId, effectiveDocumentName)
|
||||
@@ -508,24 +573,10 @@ export function Document({
|
||||
|
||||
const breadcrumbs = useMemo<BreadcrumbItem[]>(
|
||||
() =>
|
||||
combinedError
|
||||
? [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: handleNavToKB },
|
||||
{
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
onClick: handleNavToKBDetail,
|
||||
},
|
||||
{ label: 'Error' },
|
||||
]
|
||||
: [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: handleNavToKB },
|
||||
{
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
onClick: handleNavToKBDetail,
|
||||
},
|
||||
{
|
||||
documentTrail(
|
||||
combinedError
|
||||
? { label: 'Error', terminal: true }
|
||||
: {
|
||||
label: documentCrumbLabel,
|
||||
icon: DocumentIcon,
|
||||
editing: docRename.editingId
|
||||
@@ -547,13 +598,11 @@ export function Document({
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
),
|
||||
[
|
||||
combinedError,
|
||||
handleNavToKB,
|
||||
handleNavToKBDetail,
|
||||
knowledgeBaseCrumbLabel,
|
||||
documentTrail,
|
||||
documentCrumbLabel,
|
||||
DocumentIcon,
|
||||
docRename.editingId,
|
||||
@@ -987,58 +1036,23 @@ export function Document({
|
||||
? 'Create Chunk'
|
||||
: 'Save'
|
||||
|
||||
const editorBreadcrumbBase = useMemo<BreadcrumbItem[]>(
|
||||
() => [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: handleNavToKB },
|
||||
{
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
onClick: handleNavToKBDetail,
|
||||
},
|
||||
{ label: documentCrumbLabel, icon: DocumentIcon, onClick: handleBackAttempt },
|
||||
],
|
||||
[
|
||||
handleNavToKB,
|
||||
handleNavToKBDetail,
|
||||
knowledgeBaseCrumbLabel,
|
||||
documentCrumbLabel,
|
||||
DocumentIcon,
|
||||
handleBackAttempt,
|
||||
]
|
||||
)
|
||||
|
||||
const newChunkBreadcrumbs = useMemo<BreadcrumbItem[]>(
|
||||
() => [...editorBreadcrumbBase, { label: 'New Chunk', terminal: true }],
|
||||
[editorBreadcrumbBase]
|
||||
() => documentTrail({ label: 'New Chunk', terminal: true }, handleBackAttempt),
|
||||
[documentTrail, handleBackAttempt]
|
||||
)
|
||||
|
||||
const editChunkBreadcrumbs = useMemo<BreadcrumbItem[]>(
|
||||
() => [
|
||||
...editorBreadcrumbBase,
|
||||
{ label: selectedChunk ? `Chunk #${selectedChunk.chunkIndex}` : '', terminal: true },
|
||||
],
|
||||
[editorBreadcrumbBase, selectedChunk]
|
||||
() =>
|
||||
documentTrail(
|
||||
{ label: selectedChunk ? `Chunk #${selectedChunk.chunkIndex}` : '', terminal: true },
|
||||
handleBackAttempt
|
||||
),
|
||||
[documentTrail, handleBackAttempt, selectedChunk]
|
||||
)
|
||||
|
||||
const loadingBreadcrumbs = useMemo<BreadcrumbItem[]>(
|
||||
() => [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: handleNavToKB },
|
||||
{
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
onClick: handleNavToKBDetail,
|
||||
},
|
||||
{ label: documentCrumbLabel, icon: DocumentIcon, onClick: handleClearSelectedChunk },
|
||||
{ label: '…', terminal: true },
|
||||
],
|
||||
[
|
||||
handleNavToKB,
|
||||
handleNavToKBDetail,
|
||||
knowledgeBaseCrumbLabel,
|
||||
documentCrumbLabel,
|
||||
DocumentIcon,
|
||||
handleClearSelectedChunk,
|
||||
]
|
||||
() => documentTrail({ label: '…', terminal: true }, handleClearSelectedChunk),
|
||||
[documentTrail, handleClearSelectedChunk]
|
||||
)
|
||||
|
||||
const handleSaveClick = useCallback(() => {
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
|
||||
|
||||
const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'content', header: 'Content' },
|
||||
@@ -19,7 +22,7 @@ const COLUMNS = [
|
||||
const ACTIONS: ChromeActionSpec[] = [{ text: 'New chunk', icon: Plus, variant: 'primary' }]
|
||||
|
||||
const BREADCRUMBS: BreadcrumbItem[] = [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: noop },
|
||||
{ label: KNOWLEDGE_HEADER.rootLabel, icon: Database, onClick: noop },
|
||||
{ label: '…', icon: Database },
|
||||
{ label: '…', terminal: true },
|
||||
]
|
||||
|
||||
@@ -51,6 +51,12 @@ import type {
|
||||
SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
folderBreadcrumbItems,
|
||||
folderedResourceListHref,
|
||||
useFolderAncestors,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
|
||||
import {
|
||||
ActionBar,
|
||||
@@ -449,6 +455,23 @@ export function KnowledgeBase({
|
||||
const knowledgeBaseCrumbLabel = knowledgeBase?.name || passedKnowledgeBaseName || '…'
|
||||
const error = knowledgeBaseError || documentsError
|
||||
|
||||
/**
|
||||
* The base's own folder trail, so the header reads `Knowledge Base / Research / Papers`
|
||||
* exactly as the list does one level up.
|
||||
*/
|
||||
const { ancestors: folderChain } = useFolderAncestors({
|
||||
resourceType: 'knowledge_base',
|
||||
workspaceId,
|
||||
folderId: knowledgeBase?.folderId,
|
||||
})
|
||||
|
||||
const handleNavigateToFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
router.push(folderedResourceListHref('knowledge_base', workspaceId, folderId))
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit)
|
||||
|
||||
/**
|
||||
@@ -870,64 +893,99 @@ export function KnowledgeBase({
|
||||
setContextMenuDocument(null)
|
||||
}, [closeContextMenu])
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
label: 'Knowledge bases',
|
||||
icon: Database,
|
||||
onClick: () => router.push(`/workspace/${workspaceId}/knowledge`),
|
||||
},
|
||||
{
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
editing: kbRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: kbRename.editValue,
|
||||
onChange: kbRename.setEditValue,
|
||||
onSubmit: kbRename.submitRename,
|
||||
onCancel: kbRename.cancelRename,
|
||||
disabled: kbRename.isSaving,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
...(userPermissions.canEdit || userPermissions.isLoading
|
||||
? [
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: Pencil,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => kbRename.startRename(id, knowledgeBaseName),
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
icon: TagIcon,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => setShowTagsModal(true),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => setShowDeleteDialog(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const headerActions: ResourceAction[] = [
|
||||
...(userPermissions.canEdit || userPermissions.isLoading
|
||||
? [
|
||||
const breadcrumbs: BreadcrumbItem[] = useMemo(
|
||||
() =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel,
|
||||
rootIcon: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon,
|
||||
breadcrumbs: folderChain,
|
||||
onNavigate: handleNavigateToFolder,
|
||||
trailing: [
|
||||
{
|
||||
text: 'New connector',
|
||||
icon: Plus,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onSelect: () => setShowAddConnectorModal(true),
|
||||
label: knowledgeBaseCrumbLabel,
|
||||
icon: Database,
|
||||
editing: kbRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: kbRename.editValue,
|
||||
onChange: kbRename.setEditValue,
|
||||
onSubmit: kbRename.submitRename,
|
||||
onCancel: kbRename.cancelRename,
|
||||
disabled: kbRename.isSaving,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
...(userPermissions.canEdit || userPermissions.isLoading
|
||||
? [
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: Pencil,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => kbRename.startRename(id, knowledgeBaseName),
|
||||
},
|
||||
{
|
||||
label: 'Tags',
|
||||
icon: TagIcon,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => setShowTagsModal(true),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onClick: () => setShowDeleteDialog(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
],
|
||||
}),
|
||||
[
|
||||
folderChain,
|
||||
handleNavigateToFolder,
|
||||
knowledgeBaseCrumbLabel,
|
||||
knowledgeBaseName,
|
||||
id,
|
||||
kbRename.editingId,
|
||||
kbRename.editValue,
|
||||
kbRename.isSaving,
|
||||
kbRename.setEditValue,
|
||||
kbRename.submitRename,
|
||||
kbRename.cancelRename,
|
||||
kbRename.startRename,
|
||||
userPermissions.canEdit,
|
||||
userPermissions.isLoading,
|
||||
]
|
||||
)
|
||||
|
||||
const headerActions: ResourceAction[] = useMemo(
|
||||
() => [
|
||||
...(userPermissions.canEdit || userPermissions.isLoading
|
||||
? [
|
||||
{
|
||||
text: 'New connector',
|
||||
icon: Plus,
|
||||
disabled: !userPermissions.canEdit,
|
||||
onSelect: () => setShowAddConnectorModal(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
text: 'New documents',
|
||||
icon: Plus,
|
||||
onSelect: handleAddDocuments,
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
variant: 'primary',
|
||||
},
|
||||
],
|
||||
[
|
||||
userPermissions.canEdit,
|
||||
userPermissions.isLoading,
|
||||
setShowAddConnectorModal,
|
||||
handleAddDocuments,
|
||||
]
|
||||
)
|
||||
|
||||
const sortConfig: SortConfig = useMemo(
|
||||
() => ({
|
||||
@@ -1181,19 +1239,10 @@ export function KnowledgeBase({
|
||||
<>
|
||||
<Resource onContextMenu={handleEmptyContextMenu}>
|
||||
<Resource.Header
|
||||
icon={Database}
|
||||
title='Knowledge bases'
|
||||
icon={FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon}
|
||||
title={FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={[
|
||||
...headerActions,
|
||||
{
|
||||
text: 'New documents',
|
||||
icon: Plus,
|
||||
onSelect: handleAddDocuments,
|
||||
disabled: userPermissions.canEdit !== true,
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
actions={headerActions}
|
||||
/>
|
||||
<Resource.Options
|
||||
search={{
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
|
||||
|
||||
const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', header: 'Name', widthMultiplier: 0.8 },
|
||||
@@ -25,7 +28,7 @@ const ACTIONS: ChromeActionSpec[] = [
|
||||
]
|
||||
|
||||
const BREADCRUMBS: BreadcrumbItem[] = [
|
||||
{ label: 'Knowledge bases', icon: Database, onClick: noop },
|
||||
{ label: KNOWLEDGE_HEADER.rootLabel, icon: Database, onClick: noop },
|
||||
{ label: '…', icon: Database, terminal: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
import {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
FolderContextMenu,
|
||||
folderBreadcrumbItems,
|
||||
folderRow,
|
||||
@@ -110,8 +111,8 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [
|
||||
|
||||
const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small'
|
||||
|
||||
const ROOT_BREADCRUMB_LABEL = 'Knowledge bases'
|
||||
const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const
|
||||
const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel
|
||||
|
||||
function connectorCell(connectorTypes?: string[]): ResourceCell {
|
||||
if (!connectorTypes || connectorTypes.length === 0) {
|
||||
@@ -202,11 +203,17 @@ export function Knowledge() {
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } =
|
||||
useFolderNavigation({
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
workspaceId,
|
||||
})
|
||||
const {
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
ancestors: breadcrumbs,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
} = useFolderNavigation({
|
||||
resourceType: FOLDER_RESOURCE_TYPE,
|
||||
workspaceId,
|
||||
})
|
||||
|
||||
const createFolder = useCreateFolder()
|
||||
const updateFolder = useUpdateFolder()
|
||||
@@ -906,7 +913,7 @@ export function Knowledge() {
|
||||
() =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: ROOT_BREADCRUMB_LABEL,
|
||||
rootIcon: Database,
|
||||
rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon,
|
||||
breadcrumbs,
|
||||
onNavigate: setCurrentFolderId,
|
||||
currentFolderEditing:
|
||||
@@ -1106,7 +1113,7 @@ export function Knowledge() {
|
||||
<>
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header
|
||||
icon={Database}
|
||||
icon={FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon}
|
||||
title={ROOT_BREADCRUMB_LABEL}
|
||||
breadcrumbs={listBreadcrumbs}
|
||||
actions={headerActions}
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
type ChromeActionSpec,
|
||||
ResourceChromeFallback,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
|
||||
|
||||
const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
@@ -26,7 +29,7 @@ export default function KnowledgeLoading() {
|
||||
return (
|
||||
<ResourceChromeFallback
|
||||
icon={Database}
|
||||
title='Knowledge bases'
|
||||
title={KNOWLEDGE_HEADER.rootLabel}
|
||||
columns={COLUMNS}
|
||||
actions={ACTIONS}
|
||||
searchPlaceholder='Search knowledge bases...'
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Suspense } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import type { Metadata } from 'next'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
|
||||
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
|
||||
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
|
||||
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
|
||||
|
||||
const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Knowledge bases',
|
||||
title: KNOWLEDGE_HEADER.rootLabel,
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@ import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigat
|
||||
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
|
||||
import { isChatEnabled } from '@/lib/core/config/env-flags'
|
||||
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
|
||||
import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -71,11 +72,11 @@ function getResourceHref(
|
||||
case 'folder':
|
||||
return `${base}/w`
|
||||
case 'workspace_folder':
|
||||
return `${base}/files?folderId=${id}`
|
||||
return folderedResourceListHref('file', workspaceId, id)
|
||||
case 'knowledge_folder':
|
||||
return `${base}/knowledge?folderId=${id}`
|
||||
return folderedResourceListHref('knowledge_base', workspaceId, id)
|
||||
case 'table_folder':
|
||||
return `${base}/tables?folderId=${id}`
|
||||
return folderedResourceListHref('table', workspaceId, id)
|
||||
case 'chat':
|
||||
return `${base}/chat/${id}`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
|
||||
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
|
||||
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
|
||||
import { Download, Lock, Pencil, Trash, Upload } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
@@ -28,6 +28,12 @@ import {
|
||||
Resource,
|
||||
type SortConfig,
|
||||
} from '@/app/workspace/[workspaceId]/components'
|
||||
import {
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
folderBreadcrumbItems,
|
||||
folderedResourceListHref,
|
||||
useFolderAncestors,
|
||||
} from '@/app/workspace/[workspaceId]/components/folders'
|
||||
import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars'
|
||||
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -1002,9 +1008,24 @@ export function Table({
|
||||
},
|
||||
})
|
||||
|
||||
const handleNavigateBack = useCallback(() => {
|
||||
router.push(`/workspace/${workspaceId}/tables`)
|
||||
}, [router, workspaceId])
|
||||
/**
|
||||
* The table's own folder trail, so the header reads `Tables / Reports / Q3` exactly as the
|
||||
* list does one level up — and as a file's header does. Skipped in embedded mode, which
|
||||
* renders no header at all.
|
||||
*/
|
||||
const { ancestors: folderChain } = useFolderAncestors({
|
||||
resourceType: 'table',
|
||||
workspaceId,
|
||||
folderId: tableData?.folderId,
|
||||
enabled: !embedded,
|
||||
})
|
||||
|
||||
const handleNavigateToFolder = useCallback(
|
||||
(folderId: string | null) => {
|
||||
router.push(folderedResourceListHref('table', workspaceId, folderId))
|
||||
},
|
||||
[router, workspaceId]
|
||||
)
|
||||
|
||||
const handleStartTableRename = useCallback(() => {
|
||||
const data = tableDataRef.current
|
||||
@@ -1078,55 +1099,63 @@ export function Table({
|
||||
}
|
||||
|
||||
const breadcrumbs = useMemo(
|
||||
(): BreadcrumbItem[] => [
|
||||
{ label: 'Tables', onClick: handleNavigateBack },
|
||||
// While the table loads, mirror this route's loading.tsx (terminal "…" crumb)
|
||||
// so no empty-label / orphaned-chevron frame renders in between.
|
||||
tableData
|
||||
? {
|
||||
label: tableData.name,
|
||||
editing: tableHeaderRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: tableHeaderRename.editValue,
|
||||
onChange: tableHeaderRename.setEditValue,
|
||||
onSubmit: tableHeaderRename.submitRename,
|
||||
onCancel: tableHeaderRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: Pencil,
|
||||
onClick: handleStartTableRename,
|
||||
},
|
||||
// Reachable with the flag off when something is locked, so an
|
||||
// admin can always clear locks (the route allows clearing).
|
||||
...(userPermissions.canAdmin &&
|
||||
(tableLocksEnabled || lockedNouns(tableData.locks).length > 0)
|
||||
? [
|
||||
{
|
||||
label: 'Lock settings',
|
||||
icon: Lock,
|
||||
onClick: () => setShowLockSettings(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
onClick: onRequestDeleteTable,
|
||||
disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { label: '…', terminal: true },
|
||||
],
|
||||
(): BreadcrumbItem[] =>
|
||||
folderBreadcrumbItems({
|
||||
rootLabel: FOLDERED_RESOURCE_HEADERS.table.rootLabel,
|
||||
rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon,
|
||||
breadcrumbs: folderChain,
|
||||
onNavigate: handleNavigateToFolder,
|
||||
trailing: [
|
||||
// While the table loads, mirror this route's loading.tsx (terminal "…" crumb)
|
||||
// so no empty-label / orphaned-chevron frame renders in between.
|
||||
tableData
|
||||
? {
|
||||
label: tableData.name,
|
||||
editing: tableHeaderRename.editingId
|
||||
? {
|
||||
isEditing: true,
|
||||
value: tableHeaderRename.editValue,
|
||||
onChange: tableHeaderRename.setEditValue,
|
||||
onSubmit: tableHeaderRename.submitRename,
|
||||
onCancel: tableHeaderRename.cancelRename,
|
||||
}
|
||||
: undefined,
|
||||
dropdownItems: [
|
||||
{
|
||||
label: 'Rename',
|
||||
icon: Pencil,
|
||||
onClick: handleStartTableRename,
|
||||
},
|
||||
// Reachable with the flag off when something is locked, so an
|
||||
// admin can always clear locks (the route allows clearing).
|
||||
...(userPermissions.canAdmin &&
|
||||
(tableLocksEnabled || lockedNouns(tableData.locks).length > 0)
|
||||
? [
|
||||
{
|
||||
label: 'Lock settings',
|
||||
icon: Lock,
|
||||
onClick: () => setShowLockSettings(true),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: Trash,
|
||||
onClick: onRequestDeleteTable,
|
||||
disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked,
|
||||
},
|
||||
],
|
||||
}
|
||||
: { label: '…', terminal: true },
|
||||
],
|
||||
}),
|
||||
[
|
||||
handleNavigateBack,
|
||||
folderChain,
|
||||
handleNavigateToFolder,
|
||||
userPermissions.canAdmin,
|
||||
userPermissions.canEdit,
|
||||
tableData,
|
||||
tableLocksEnabled,
|
||||
tableHeaderRename.editingId,
|
||||
tableHeaderRename.editValue,
|
||||
tableHeaderRename.setEditValue,
|
||||
@@ -1332,7 +1361,7 @@ export function Table({
|
||||
<Resource>
|
||||
{!embedded && (
|
||||
<Resource.Header
|
||||
icon={TableIcon}
|
||||
icon={FOLDERED_RESOURCE_HEADERS.table.rootIcon}
|
||||
breadcrumbs={breadcrumbs}
|
||||
aside={
|
||||
<div className='flex items-center gap-1.5'>
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import {
|
||||
buildDescendantIndex,
|
||||
buildMoveOptions,
|
||||
FOLDERED_RESOURCE_HEADERS,
|
||||
FolderContextMenu,
|
||||
folderBreadcrumbItems,
|
||||
folderRow,
|
||||
@@ -93,7 +94,7 @@ const COLUMNS: ResourceColumn[] = [
|
||||
]
|
||||
|
||||
/** Root label for breadcrumbs and the "move to workspace root" destination. */
|
||||
const ROOT_LABEL = 'Tables'
|
||||
const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel
|
||||
|
||||
const EMPTY_TABLES: TableDefinition[] = []
|
||||
|
||||
@@ -133,7 +134,7 @@ export function Tables() {
|
||||
const {
|
||||
currentFolderId,
|
||||
setCurrentFolderId,
|
||||
breadcrumbs: folderChain,
|
||||
ancestors: folderChain,
|
||||
folders,
|
||||
folderById,
|
||||
foldersResolved,
|
||||
@@ -512,6 +513,7 @@ export function Tables() {
|
||||
folderBreadcrumbItems({
|
||||
breadcrumbs: folderChain,
|
||||
rootLabel: ROOT_LABEL,
|
||||
rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon,
|
||||
onNavigate: setCurrentFolderId,
|
||||
currentFolderActions,
|
||||
currentFolderEditing,
|
||||
@@ -1073,7 +1075,7 @@ export function Tables() {
|
||||
<>
|
||||
<Resource onContextMenu={handleContentContextMenu}>
|
||||
<Resource.Header
|
||||
icon={TableIcon}
|
||||
icon={FOLDERED_RESOURCE_HEADERS.table.rootIcon}
|
||||
title={ROOT_LABEL}
|
||||
breadcrumbs={breadcrumbs}
|
||||
actions={headerActions}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { folderAncestorChain } from '@/lib/folders/tree'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
/**
|
||||
* Returns true when the folder or one of its ancestors is locked. Used to
|
||||
* mirror server-side cascading folder lock policy on the client without an
|
||||
* extra round-trip.
|
||||
*
|
||||
* Walks inline rather than through {@link folderAncestorChain} on purpose: this runs per row
|
||||
* on every sidebar render, and stopping at the first locked ancestor beats materializing the
|
||||
* whole chain to scan it. Same for {@link findLockedAncestorFolder} below.
|
||||
*/
|
||||
export function isFolderOrAncestorLocked(
|
||||
folderId: string | null | undefined,
|
||||
@@ -36,21 +41,7 @@ export function getFolderPath(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
separator = ' / '
|
||||
): string | null {
|
||||
if (!folderId) return null
|
||||
|
||||
const segments: string[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentFolderId: string | null | undefined = folderId
|
||||
|
||||
while (currentFolderId) {
|
||||
if (visited.has(currentFolderId)) break
|
||||
visited.add(currentFolderId)
|
||||
const folder: WorkflowFolder | undefined = folders[currentFolderId]
|
||||
if (!folder) break
|
||||
segments.unshift(folder.name)
|
||||
currentFolderId = folder.parentId
|
||||
}
|
||||
|
||||
const segments = folderAncestorChain(folderId, (id) => folders[id]).map((folder) => folder.name)
|
||||
return segments.length > 0 ? segments.join(separator) : null
|
||||
}
|
||||
|
||||
|
||||
@@ -38,32 +38,54 @@ export function getChildFolders(
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
/** The minimum a node must expose to be walked upward. */
|
||||
export interface FolderAncestorNode {
|
||||
id: string
|
||||
parentId: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-first ancestor chain of `folderId`, as folder objects — callers need the ids (cycle
|
||||
* checks, expanding each ancestor), not just the names, which is why this is distinct from
|
||||
* the string-returning `getFolderPath` in `@/hooks/queries/utils/folder-tree`.
|
||||
* Root-first ancestor chain of `folderId`, that folder last. The one upward walk in the app —
|
||||
* every folder path, breadcrumb trail, and drop-target check goes through it.
|
||||
*
|
||||
* `visited` is not defensive padding. The client folder map is written optimistically by
|
||||
* `useReorderFolders`, which sets `parentId` without validating the result, so a cycle is
|
||||
* reachable in cache even though the server rejects one. Without the guard this loops
|
||||
* forever and hangs the tab.
|
||||
* `lookup` rather than a fixed container because the folder index exists in three shapes
|
||||
* (a `Record` from `useFolderMap`, a `Map` built by `useFolderAncestors`, the Files tree's own
|
||||
* rows), and generic over the node so callers keep their own row type.
|
||||
*
|
||||
* Truncates at the first unresolvable link, returning what it walked. `visited` is not
|
||||
* defensive padding: the client folder map is written optimistically by `useReorderFolders`,
|
||||
* which sets `parentId` without validating the result, so a cycle is reachable in cache even
|
||||
* though the server rejects one. Without the guard this loops forever and hangs the tab.
|
||||
*/
|
||||
export function folderAncestorChain<T extends FolderAncestorNode>(
|
||||
folderId: string | null | undefined,
|
||||
lookup: (id: string) => T | undefined
|
||||
): T[] {
|
||||
const chain: T[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentId: string | null | undefined = folderId
|
||||
|
||||
while (currentId && !visited.has(currentId)) {
|
||||
visited.add(currentId)
|
||||
const folder = lookup(currentId)
|
||||
if (!folder) break
|
||||
chain.unshift(folder)
|
||||
currentId = folder.parentId
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-first ancestor chain as folder objects — callers need the ids (cycle checks, expanding
|
||||
* each ancestor), not just the names, which is why this is distinct from the string-returning
|
||||
* `getFolderPath` in `@/hooks/queries/utils/folder-tree`.
|
||||
*/
|
||||
export function getFolderPath(
|
||||
folders: Record<string, WorkflowFolder>,
|
||||
folderId: string
|
||||
): WorkflowFolder[] {
|
||||
const path: WorkflowFolder[] = []
|
||||
const visited = new Set<string>()
|
||||
let currentId: string | null = folderId
|
||||
|
||||
while (currentId && folders[currentId] && !visited.has(currentId)) {
|
||||
visited.add(currentId)
|
||||
const folder: WorkflowFolder = folders[currentId]
|
||||
path.unshift(folder)
|
||||
currentId = folder.parentId
|
||||
}
|
||||
|
||||
return path
|
||||
return folderAncestorChain(folderId, (id) => folders[id])
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user