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 number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'

function folder(id: string, name: string, parentId: string | null, updatedAt: string) {
return { id, name, parentId, updatedAt: new Date(updatedAt) }
}

function item(id: string, name: string, folderId: string | null, updatedAt: string) {
return { id, name, folderId, updatedAt: new Date(updatedAt) }
}

const NONE: ReadonlySet<string> = new Set()

function build(
folders: ReturnType<typeof folder>[],
items: ReturnType<typeof item>[],
pinned?: { folders?: ReadonlySet<string>; items?: ReadonlySet<string> }
) {
return buildFlyoutEntries({
folders,
items,
pinnedFolderIds: pinned?.folders ?? NONE,
pinnedItemIds: pinned?.items ?? NONE,
hrefForItem: (row) => `/x/${row.id}`,
})
}

describe('buildFlyoutEntries', () => {
it('orders folders and items together, most-recently-updated first', () => {
const entries = build(
[
folder('f1', 'Older folder', null, '2026-01-01'),
folder('f2', 'Newest', null, '2026-03-01'),
],
[item('i1', 'Middle', null, '2026-02-01')]
)

expect(entries.map((entry) => entry.id)).toEqual(['f2', 'i1', 'f1'])
})

it('floats pinned rows above newer unpinned ones, matching the list pages', () => {
const entries = build(
[folder('f1', 'Folder', null, '2026-03-01')],
[item('i1', 'Pinned', null, '2026-01-01'), item('i2', 'Newest', null, '2026-04-01')],
{ items: new Set(['i1']) }
)

expect(entries.map((entry) => entry.id)).toEqual(['i1', 'i2', 'f1'])
})

it('breaks ties on name', () => {
const entries = build(
[],
[
item('b', 'Beta', null, '2026-01-01'),
item('c', 'Alpha', null, '2026-01-01'),
item('a', 'Gamma', null, '2026-01-01'),
]
)

expect(entries.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
})

it('nests items under their folder and links each one', () => {
const entries = build(
[folder('f1', 'Reports', null, '2026-01-01'), folder('f2', 'Q1', 'f1', '2026-01-02')],
[item('i1', 'Revenue', 'f2', '2026-01-03')]
)

expect(entries).toEqual([
{
kind: 'folder',
id: 'f1',
name: 'Reports',
pinned: false,
children: [
{
kind: 'folder',
id: 'f2',
name: 'Q1',
pinned: false,
children: [{ kind: 'item', id: 'i1', name: 'Revenue', pinned: false, href: '/x/i1' }],
},
],
},
])
})

it('hoists a folder and an item whose parent folder is gone to the root', () => {
const entries = build(
[folder('f1', 'Orphan', 'archived-folder', '2026-01-02')],
[item('i1', 'Loose', 'archived-folder', '2026-01-01')]
)

expect(entries.map((entry) => entry.id)).toEqual(['f1', 'i1'])
expect(entries[0]).toMatchObject({ kind: 'folder', children: [] })
})

it('drops folders reachable only through a parent cycle instead of descending it', () => {
const entries = build(
[
folder('a', 'A', 'b', '2026-01-01'),
folder('b', 'B', 'a', '2026-01-01'),
folder('root', 'Root', null, '2026-01-01'),
],
[]
)

expect(entries.map((entry) => entry.id)).toEqual(['root'])
})

it('accepts serialized date strings and sorts undated rows last', () => {
const entries = buildFlyoutEntries({
folders: [],
items: [
{ id: 'i1', name: 'Undated', folderId: null, updatedAt: 'not-a-date' },
{ id: 'i2', name: 'Dated', folderId: null, updatedAt: '2026-01-01T00:00:00.000Z' },
],
pinnedFolderIds: NONE,
pinnedItemIds: NONE,
hrefForItem: (row) => `/x/${row.id}`,
})

expect(entries.map((entry) => entry.id)).toEqual(['i2', 'i1'])
})

it('treats a missing folderId as the root', () => {
const entries = buildFlyoutEntries({
folders: [],
items: [{ id: 'i1', name: 'Rootless', updatedAt: new Date('2026-01-01') }],
pinnedFolderIds: NONE,
pinnedItemIds: NONE,
hrefForItem: (row) => `/x/${row.id}`,
})

expect(entries).toEqual([
{ kind: 'item', id: 'i1', name: 'Rootless', pinned: false, href: '/x/i1' },
])
})

it('keeps each nesting level ordered independently, not just the root', () => {
const entries = build(
[folder('f1', 'Root folder', null, '2026-05-01')],
[
item('deep-old', 'Deep old', 'f1', '2026-01-01'),
item('deep-new', 'Deep new', 'f1', '2026-04-01'),
item('root-mid', 'Root mid', null, '2026-03-01'),
]
)

expect(entries.map((entry) => entry.id)).toEqual(['f1', 'root-mid'])
const nested = entries[0]
expect(nested.kind).toBe('folder')
if (nested.kind !== 'folder') throw new Error('expected a folder')
expect(nested.children.map((child) => child.id)).toEqual(['deep-new', 'deep-old'])
})

it('preserves the full depth of the folder chain', () => {
const entries = build(
[
folder('a', 'A', null, '2026-01-01'),
folder('b', 'B', 'a', '2026-01-01'),
folder('c', 'C', 'b', '2026-01-01'),
],
[item('leaf', 'Leaf', 'c', '2026-01-01')]
)

const depth = (rows: ReturnType<typeof build>): number => {
const nested = rows.find((row) => row.kind === 'folder')
return nested && nested.kind === 'folder' ? 1 + depth(nested.children) : 0
}
expect(depth(entries)).toBe(3)
})

it('keeps an empty folder in the tree rather than dropping it', () => {
const entries = build(
[folder('empty', 'Nothing here', null, '2026-01-01')],
[item('i1', 'Loose', null, '2026-01-02')]
)

expect(entries.map((entry) => entry.id)).toEqual(['i1', 'empty'])
expect(entries[1]).toMatchObject({ kind: 'folder', children: [] })
})

it('marks pinned folders and pinned resources so the ordering is legible', () => {
const entries = build(
[folder('f1', 'Folder', null, '2026-01-01')],
[item('i1', 'Table', null, '2026-01-02')],
{ folders: new Set(['f1']), items: new Set(['i1']) }
)

expect(entries.map((entry) => entry.pinned)).toEqual([true, true])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
type SortableResource,
sortResources,
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'

/** A folder row a resource flyout can render, from any foldered workspace surface. */
interface FlyoutFolderSource {
id: string
name: string
parentId: string | null
updatedAt: Date | string
}

/** A resource row a flyout can render, from any foldered workspace surface. */
interface FlyoutItemSource {
id: string
name: string
folderId?: string | null
updatedAt: Date | string
}

/** One row of a resource flyout: a folder that recurses, or a linked resource. */
export type FlyoutEntry =
| { kind: 'folder'; id: string; name: string; pinned: boolean; children: FlyoutEntry[] }
| { kind: 'item'; id: string; name: string; pinned: boolean; href: string }

export interface BuildFlyoutEntriesParams<Item extends FlyoutItemSource> {
folders: FlyoutFolderSource[]
items: Item[]
pinnedFolderIds: ReadonlySet<string>
pinnedItemIds: ReadonlySet<string>
hrefForItem: (item: Item) => string
}

function flyoutSortTime(value: Date | string): number {
const time = value instanceof Date ? value.getTime() : Date.parse(value)
return Number.isNaN(time) ? 0 : time
}

/**
* Builds the ordered row tree a foldered resource's flyout renders.
*
* Each level is sorted by the shared {@link sortResources}, on the most-recently-updated
* key its list page defaults to — so pinned rows float, folders interleave with the
* resources beside them, and the flyout keeps reading in the same order as the page it
* links into rather than carrying a second copy of that rule. `pinned` rides along on each
* row because that ordering reads as arbitrary without the indicator the rows render from
* it — the same pairing `Resource`'s own cells make.
*
* A folder whose parent no longer exists, and a resource whose `folderId` names no live
* folder, surface at the root — the same fallback the list pages apply when a folder is
* archived out from under its contents, so neither goes unreachable. A folder only
* reachable through a parent cycle is dropped, as it is by the sidebar's folder tree: the
* client folder cache is written optimistically, so a cycle is reachable there even though
* the server rejects one, and descending it would hang the tab.
*/
export function buildFlyoutEntries<Item extends FlyoutItemSource>({
folders,
items,
pinnedFolderIds,
pinnedItemIds,
hrefForItem,
}: BuildFlyoutEntriesParams<Item>): FlyoutEntry[] {
const folderIds = new Set(folders.map((folder) => folder.id))

const foldersByParent = new Map<string | null, FlyoutFolderSource[]>()
for (const folder of folders) {
const parentId = folder.parentId && folderIds.has(folder.parentId) ? folder.parentId : null
const siblings = foldersByParent.get(parentId)
if (siblings) siblings.push(folder)
else foldersByParent.set(parentId, [folder])
}

const itemsByFolder = new Map<string | null, Item[]>()
for (const item of items) {
const folderId = item.folderId && folderIds.has(item.folderId) ? item.folderId : null
const siblings = itemsByFolder.get(folderId)
if (siblings) siblings.push(item)
else itemsByFolder.set(folderId, [item])
}

const buildLevel = (parentId: string | null): FlyoutEntry[] => {
const rows: SortableResource<FlyoutEntry>[] = []
for (const folder of foldersByParent.get(parentId) ?? []) {
const pinned = pinnedFolderIds.has(folder.id)
rows.push({
item: {
kind: 'folder',
id: folder.id,
name: folder.name,
pinned,
children: buildLevel(folder.id),
},
pinned,
name: folder.name,
key: flyoutSortTime(folder.updatedAt),
})
}
for (const item of itemsByFolder.get(parentId) ?? []) {
const pinned = pinnedItemIds.has(item.id)
rows.push({
item: { kind: 'item', id: item.id, name: item.name, pinned, href: hrefForItem(item) },
pinned,
name: item.name,
key: flyoutSortTime(item.updatedAt),
})
}
return sortResources(rows, 'desc').map((row) => row.item)
}

return buildLevel(null)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ElementType } from 'react'
import type { ComponentType } 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'
Expand All @@ -17,7 +17,7 @@ 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
rootIcon: ComponentType<{ className?: string }>
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
listSegment: string
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { readRowDragPayload, writeRowDragPayload } from './drag-payload'
export type { BuildFlyoutEntriesParams, FlyoutEntry } from './flyout-entries'
export { buildFlyoutEntries } from './flyout-entries'
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
export { FolderContextMenu } from './folder-context-menu'
Expand Down
Loading
Loading