Skip to content

Commit 4b0a24b

Browse files
committed
feat(sidebar): add Tables and Files flyouts to the collapsed rail
Chats and Workflows already open a hover flyout on the collapsed rail; Tables and Files were plain links. Both now list their contents, with folders as submenus and the open resource marked. The chip stays a real link, so clicking still opens the list page and right-click still reaches the nav context menu. Each flyout owns its queries and mounts only when the menu opens: a hook on the sidebar keeps its cache subscription on every workspace route even when disabled, so an unrelated writer would re-render the whole sidebar for a closed flyout. Rows are ordered by the shared sortResources, so pinned rows float and the flyout reads in the same order as the page it links into. Also removes two dead components (CollapsedFileFolderItems, FileList) that were exported but never rendered, and extracts SidebarNavChip so the rail chip has one definition.
1 parent 5bc2955 commit 4b0a24b

15 files changed

Lines changed: 846 additions & 422 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'
6+
7+
function folder(id: string, name: string, parentId: string | null, updatedAt: string) {
8+
return { id, name, parentId, updatedAt: new Date(updatedAt) }
9+
}
10+
11+
function item(id: string, name: string, folderId: string | null, updatedAt: string) {
12+
return { id, name, folderId, updatedAt: new Date(updatedAt) }
13+
}
14+
15+
const NONE: ReadonlySet<string> = new Set()
16+
17+
function build(
18+
folders: ReturnType<typeof folder>[],
19+
items: ReturnType<typeof item>[],
20+
pinned?: { folders?: ReadonlySet<string>; items?: ReadonlySet<string> }
21+
) {
22+
return buildFlyoutEntries({
23+
folders,
24+
items,
25+
pinnedFolderIds: pinned?.folders ?? NONE,
26+
pinnedItemIds: pinned?.items ?? NONE,
27+
hrefForItem: (row) => `/x/${row.id}`,
28+
})
29+
}
30+
31+
describe('buildFlyoutEntries', () => {
32+
it('orders folders and items together, most-recently-updated first', () => {
33+
const entries = build(
34+
[
35+
folder('f1', 'Older folder', null, '2026-01-01'),
36+
folder('f2', 'Newest', null, '2026-03-01'),
37+
],
38+
[item('i1', 'Middle', null, '2026-02-01')]
39+
)
40+
41+
expect(entries.map((entry) => entry.id)).toEqual(['f2', 'i1', 'f1'])
42+
})
43+
44+
it('floats pinned rows above newer unpinned ones, matching the list pages', () => {
45+
const entries = build(
46+
[folder('f1', 'Folder', null, '2026-03-01')],
47+
[item('i1', 'Pinned', null, '2026-01-01'), item('i2', 'Newest', null, '2026-04-01')],
48+
{ items: new Set(['i1']) }
49+
)
50+
51+
expect(entries.map((entry) => entry.id)).toEqual(['i1', 'i2', 'f1'])
52+
})
53+
54+
it('breaks ties on name', () => {
55+
const entries = build(
56+
[],
57+
[
58+
item('b', 'Beta', null, '2026-01-01'),
59+
item('c', 'Alpha', null, '2026-01-01'),
60+
item('a', 'Gamma', null, '2026-01-01'),
61+
]
62+
)
63+
64+
expect(entries.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
65+
})
66+
67+
it('nests items under their folder and links each one', () => {
68+
const entries = build(
69+
[folder('f1', 'Reports', null, '2026-01-01'), folder('f2', 'Q1', 'f1', '2026-01-02')],
70+
[item('i1', 'Revenue', 'f2', '2026-01-03')]
71+
)
72+
73+
expect(entries).toEqual([
74+
{
75+
kind: 'folder',
76+
id: 'f1',
77+
name: 'Reports',
78+
children: [
79+
{
80+
kind: 'folder',
81+
id: 'f2',
82+
name: 'Q1',
83+
children: [{ kind: 'item', id: 'i1', name: 'Revenue', href: '/x/i1' }],
84+
},
85+
],
86+
},
87+
])
88+
})
89+
90+
it('hoists a folder and an item whose parent folder is gone to the root', () => {
91+
const entries = build(
92+
[folder('f1', 'Orphan', 'archived-folder', '2026-01-02')],
93+
[item('i1', 'Loose', 'archived-folder', '2026-01-01')]
94+
)
95+
96+
expect(entries.map((entry) => entry.id)).toEqual(['f1', 'i1'])
97+
expect(entries[0]).toMatchObject({ kind: 'folder', children: [] })
98+
})
99+
100+
it('drops folders reachable only through a parent cycle instead of descending it', () => {
101+
const entries = build(
102+
[
103+
folder('a', 'A', 'b', '2026-01-01'),
104+
folder('b', 'B', 'a', '2026-01-01'),
105+
folder('root', 'Root', null, '2026-01-01'),
106+
],
107+
[]
108+
)
109+
110+
expect(entries.map((entry) => entry.id)).toEqual(['root'])
111+
})
112+
113+
it('accepts serialized date strings and sorts undated rows last', () => {
114+
const entries = buildFlyoutEntries({
115+
folders: [],
116+
items: [
117+
{ id: 'i1', name: 'Undated', folderId: null, updatedAt: 'not-a-date' },
118+
{ id: 'i2', name: 'Dated', folderId: null, updatedAt: '2026-01-01T00:00:00.000Z' },
119+
],
120+
pinnedFolderIds: NONE,
121+
pinnedItemIds: NONE,
122+
hrefForItem: (row) => `/x/${row.id}`,
123+
})
124+
125+
expect(entries.map((entry) => entry.id)).toEqual(['i2', 'i1'])
126+
})
127+
128+
it('treats a missing folderId as the root', () => {
129+
const entries = buildFlyoutEntries({
130+
folders: [],
131+
items: [{ id: 'i1', name: 'Rootless', updatedAt: new Date('2026-01-01') }],
132+
pinnedFolderIds: NONE,
133+
pinnedItemIds: NONE,
134+
hrefForItem: (row) => `/x/${row.id}`,
135+
})
136+
137+
expect(entries).toEqual([{ kind: 'item', id: 'i1', name: 'Rootless', href: '/x/i1' }])
138+
})
139+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import {
2+
type SortableResource,
3+
sortResources,
4+
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'
5+
6+
/** A folder row a resource flyout can render, from any foldered workspace surface. */
7+
interface FlyoutFolderSource {
8+
id: string
9+
name: string
10+
parentId: string | null
11+
updatedAt: Date | string
12+
}
13+
14+
/** A resource row a flyout can render, from any foldered workspace surface. */
15+
interface FlyoutItemSource {
16+
id: string
17+
name: string
18+
folderId?: string | null
19+
updatedAt: Date | string
20+
}
21+
22+
/** One row of a resource flyout: a folder that recurses, or a linked resource. */
23+
export type FlyoutEntry =
24+
| { kind: 'folder'; id: string; name: string; children: FlyoutEntry[] }
25+
| { kind: 'item'; id: string; name: string; href: string }
26+
27+
export interface BuildFlyoutEntriesParams<Item extends FlyoutItemSource> {
28+
folders: FlyoutFolderSource[]
29+
items: Item[]
30+
pinnedFolderIds: ReadonlySet<string>
31+
pinnedItemIds: ReadonlySet<string>
32+
hrefForItem: (item: Item) => string
33+
}
34+
35+
function flyoutSortTime(value: Date | string): number {
36+
const time = value instanceof Date ? value.getTime() : Date.parse(value)
37+
return Number.isNaN(time) ? 0 : time
38+
}
39+
40+
/**
41+
* Builds the ordered row tree a foldered resource's flyout renders.
42+
*
43+
* Each level is sorted by the shared {@link sortResources}, on the most-recently-updated
44+
* key its list page defaults to — so pinned rows float, folders interleave with the
45+
* resources beside them, and the flyout keeps reading in the same order as the page it
46+
* links into rather than carrying a second copy of that rule.
47+
*
48+
* A folder whose parent no longer exists, and a resource whose `folderId` names no live
49+
* folder, surface at the root — the same fallback the list pages apply when a folder is
50+
* archived out from under its contents, so neither goes unreachable. A folder only
51+
* reachable through a parent cycle is dropped, as it is by the sidebar's folder tree: the
52+
* client folder cache is written optimistically, so a cycle is reachable there even though
53+
* the server rejects one, and descending it would hang the tab.
54+
*/
55+
export function buildFlyoutEntries<Item extends FlyoutItemSource>({
56+
folders,
57+
items,
58+
pinnedFolderIds,
59+
pinnedItemIds,
60+
hrefForItem,
61+
}: BuildFlyoutEntriesParams<Item>): FlyoutEntry[] {
62+
const folderIds = new Set(folders.map((folder) => folder.id))
63+
64+
const foldersByParent = new Map<string | null, FlyoutFolderSource[]>()
65+
for (const folder of folders) {
66+
const parentId = folder.parentId && folderIds.has(folder.parentId) ? folder.parentId : null
67+
const siblings = foldersByParent.get(parentId)
68+
if (siblings) siblings.push(folder)
69+
else foldersByParent.set(parentId, [folder])
70+
}
71+
72+
const itemsByFolder = new Map<string | null, Item[]>()
73+
for (const item of items) {
74+
const folderId = item.folderId && folderIds.has(item.folderId) ? item.folderId : null
75+
const siblings = itemsByFolder.get(folderId)
76+
if (siblings) siblings.push(item)
77+
else itemsByFolder.set(folderId, [item])
78+
}
79+
80+
const buildLevel = (parentId: string | null): FlyoutEntry[] => {
81+
const rows: SortableResource<FlyoutEntry>[] = []
82+
for (const folder of foldersByParent.get(parentId) ?? []) {
83+
rows.push({
84+
item: { kind: 'folder', id: folder.id, name: folder.name, children: buildLevel(folder.id) },
85+
pinned: pinnedFolderIds.has(folder.id),
86+
name: folder.name,
87+
key: flyoutSortTime(folder.updatedAt),
88+
})
89+
}
90+
for (const item of itemsByFolder.get(parentId) ?? []) {
91+
rows.push({
92+
item: { kind: 'item', id: item.id, name: item.name, href: hrefForItem(item) },
93+
pinned: pinnedItemIds.has(item.id),
94+
name: item.name,
95+
key: flyoutSortTime(item.updatedAt),
96+
})
97+
}
98+
return sortResources(rows, 'desc').map((row) => row.item)
99+
}
100+
101+
return buildLevel(null)
102+
}

apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ElementType } from 'react'
1+
import type { ComponentType } from 'react'
22
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
33
import type { FolderResourceType } from '@/lib/api/contracts/folders'
44
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'
@@ -17,7 +17,7 @@ export interface FolderedResourceHeaderMeta {
1717
/** Root crumb label, and the page title at the workspace root. */
1818
rootLabel: string
1919
/** Icon on the root crumb, which is also what opens the header's "Path" popover. */
20-
rootIcon: ElementType
20+
rootIcon: ComponentType<{ className?: string }>
2121
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
2222
listSegment: string
2323
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { readRowDragPayload, writeRowDragPayload } from './drag-payload'
2+
export type { BuildFlyoutEntriesParams, FlyoutEntry } from './flyout-entries'
3+
export { buildFlyoutEntries } from './flyout-entries'
24
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
35
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
46
export { FolderContextMenu } from './folder-context-menu'
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('next/link', () => ({
9+
default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => (
10+
<a href={href} {...props}>
11+
{children}
12+
</a>
13+
),
14+
}))
15+
16+
import { Table } from '@sim/emcn/icons'
17+
import {
18+
CollapsedResourceFlyout,
19+
CollapsedSidebarMenu,
20+
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu'
21+
22+
function stubHoverMenu(isOpen: boolean) {
23+
return {
24+
isOpen,
25+
open: vi.fn(),
26+
close: vi.fn(),
27+
setLocked: vi.fn(),
28+
triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() },
29+
contentProps: {
30+
onMouseEnter: vi.fn(),
31+
onMouseLeave: vi.fn(),
32+
onCloseAutoFocus: vi.fn(),
33+
},
34+
} as unknown as Parameters<typeof CollapsedSidebarMenu>[0]['hover']
35+
}
36+
37+
describe('CollapsedSidebarMenu nav-link trigger', () => {
38+
let container: HTMLDivElement
39+
let root: Root
40+
41+
beforeEach(() => {
42+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
43+
vi.stubGlobal(
44+
'ResizeObserver',
45+
class {
46+
observe() {}
47+
unobserve() {}
48+
disconnect() {}
49+
}
50+
)
51+
container = document.createElement('div')
52+
document.body.appendChild(container)
53+
root = createRoot(container)
54+
})
55+
56+
afterEach(() => {
57+
act(() => root.unmount())
58+
container.remove()
59+
vi.unstubAllGlobals()
60+
})
61+
62+
function renderMenu(
63+
options: { isOpen?: boolean; onContextMenu?: (e: unknown, href: string) => void } = {}
64+
) {
65+
act(() => {
66+
root.render(
67+
<CollapsedSidebarMenu
68+
hover={stubHoverMenu(options.isOpen ?? false)}
69+
navLink={{
70+
item: { id: 'tables', label: 'Tables', icon: Table, href: '/workspace/w1/tables' },
71+
active: false,
72+
onContextMenu: options.onContextMenu,
73+
}}
74+
>
75+
<CollapsedResourceFlyout
76+
entries={[{ kind: 'item', id: 't1', name: 'Leads', href: '/workspace/w1/tables/t1' }]}
77+
icon={Table}
78+
emptyLabel='No tables yet'
79+
/>
80+
</CollapsedSidebarMenu>
81+
)
82+
})
83+
const trigger = container.querySelector('a')
84+
if (!trigger) throw new Error('trigger anchor not rendered')
85+
return trigger
86+
}
87+
88+
it('renders the rail chip as a real link, not the primitive button', () => {
89+
const trigger = renderMenu()
90+
91+
expect(trigger.getAttribute('href')).toBe('/workspace/w1/tables')
92+
expect(trigger.textContent).toContain('Tables')
93+
expect(container.querySelector('button')).toBeNull()
94+
/* Radix's trigger is a button primitive; its `type` must not leak onto the anchor. */
95+
expect(trigger.hasAttribute('type')).toBe(false)
96+
})
97+
98+
it('activates the link on Enter, which Radix would otherwise swallow to toggle the menu', () => {
99+
const trigger = renderMenu()
100+
const onClick = vi.fn((e: Event) => e.preventDefault())
101+
trigger.addEventListener('click', onClick)
102+
103+
act(() => {
104+
trigger.dispatchEvent(
105+
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
106+
)
107+
})
108+
109+
expect(onClick).toHaveBeenCalledTimes(1)
110+
})
111+
112+
it('forwards a right-click to the nav item context menu with its href', () => {
113+
const onContextMenu = vi.fn()
114+
const trigger = renderMenu({ onContextMenu })
115+
116+
act(() => {
117+
trigger.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
118+
})
119+
120+
expect(onContextMenu).toHaveBeenCalledWith(expect.anything(), '/workspace/w1/tables')
121+
})
122+
123+
it('lists the resource rows once the flyout is open', () => {
124+
renderMenu({ isOpen: true })
125+
126+
const row = document.querySelector('a[href="/workspace/w1/tables/t1"]')
127+
expect(row?.textContent).toContain('Leads')
128+
})
129+
})

0 commit comments

Comments
 (0)