diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts new file mode 100644 index 00000000000..6645f469cd7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts @@ -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 = new Set() + +function build( + folders: ReturnType[], + items: ReturnType[], + pinned?: { folders?: ReadonlySet; items?: ReadonlySet } +) { + 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): 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]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts new file mode 100644 index 00000000000..d9ba6729ef6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts @@ -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 { + folders: FlyoutFolderSource[] + items: Item[] + pinnedFolderIds: ReadonlySet + pinnedItemIds: ReadonlySet + 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({ + folders, + items, + pinnedFolderIds, + pinnedItemIds, + hrefForItem, +}: BuildFlyoutEntriesParams): FlyoutEntry[] { + const folderIds = new Set(folders.map((folder) => folder.id)) + + const foldersByParent = new Map() + 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() + 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[] = [] + 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) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts index f370e98762c..45ea37ead2a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts @@ -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' @@ -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 } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index 93f2ecf13f1..fb012cfb42b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx new file mode 100644 index 00000000000..f786ac6b5e8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) + +import { Table } from '@sim/emcn/icons' +import { + CollapsedResourceFlyout, + CollapsedSidebarMenu, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu' + +function stubHoverMenu(isOpen: boolean) { + return { + isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { + onMouseEnter: vi.fn(), + onMouseLeave: vi.fn(), + onCloseAutoFocus: vi.fn(), + }, + } as unknown as Parameters[0]['hover'] +} + +describe('CollapsedSidebarMenu nav-link trigger', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + function renderMenu( + options: { isOpen?: boolean; onContextMenu?: (e: unknown, href: string) => void } = {} + ) { + act(() => { + root.render( + + + + ) + }) + const trigger = container.querySelector('a') + if (!trigger) throw new Error('trigger anchor not rendered') + return trigger + } + + it('renders the rail chip as a real link, not the primitive button', () => { + const trigger = renderMenu() + + expect(trigger.getAttribute('href')).toBe('/workspace/w1/tables') + expect(trigger.textContent).toContain('Tables') + expect(container.querySelector('button')).toBeNull() + /* Radix's trigger is a button primitive; its `type` must not leak onto the anchor. */ + expect(trigger.hasAttribute('type')).toBe(false) + }) + + it('activates the link on Enter, which Radix would otherwise swallow to toggle the menu', () => { + const trigger = renderMenu() + const onClick = vi.fn((e: Event) => e.preventDefault()) + trigger.addEventListener('click', onClick) + + act(() => { + trigger.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('forwards a right-click to the nav item context menu with its href', () => { + const onContextMenu = vi.fn() + const trigger = renderMenu({ onContextMenu }) + + act(() => { + trigger.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) + }) + + expect(onContextMenu).toHaveBeenCalledWith(expect.anything(), '/workspace/w1/tables') + }) + + it('lists the resource rows once the flyout is open', () => { + renderMenu({ isOpen: true }) + + const row = document.querySelector('a[href="/workspace/w1/tables/t1"]') + expect(row?.textContent).toContain('Leads') + }) + + it('marks a pinned row, so sorting it to the top does not read as arbitrary', () => { + renderMenu({ isOpen: true }) + + const pinnedRow = document.querySelector('a[href="/workspace/w1/tables/t2"]') + const plainRow = document.querySelector('a[href="/workspace/w1/tables/t1"]') + expect(pinnedRow?.querySelector('[aria-label="Pinned"]')).not.toBeNull() + expect(plainRow?.querySelector('[aria-label="Pinned"]')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 831b3b0f7dd..f48a6c1eb81 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -1,5 +1,6 @@ -import { type MouseEvent as ReactMouseEvent, useState } from 'react' +import { type ComponentType, type MouseEvent as ReactMouseEvent, useState } from 'react' import { + Chip, chipVariants, cn, DropdownMenu, @@ -11,110 +12,117 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Loader, } from '@sim/emcn' -import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons' +import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons' import Link from 'next/link' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders' +import { + SidebarNavChip, + type SidebarNavItemData, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip' import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' -import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -interface FileFolderFlyoutNode extends WorkspaceFileFolderApi { - children: FileFolderFlyoutNode[] - files: WorkspaceFileRecord[] +interface CollapsedResourceFlyoutProps { + entries: FlyoutEntry[] + /** Icon for the resource rows. Folders always carry the folder glyph. */ + icon: ComponentType<{ className?: string }> + /** Resource open on the current route, so its row reads as selected. */ + currentItemId?: string + /** + * True until the lists that decide which rows EXIST have resolved once — the resources and + * their folders. Both are needed before anything renders: a resource whose folder has not + * arrived yet would show at the root and then jump into it. Pins are deliberately not + * waited on, since they only reorder rows that are already correct. + */ + isLoading?: boolean + emptyLabel: string } -type FileFlyoutEntry = - | { kind: 'folder'; id: string; name: string; folder: FileFolderFlyoutNode } - | { kind: 'file'; id: string; name: string; file: WorkspaceFileRecord } - /** - * Orders one level of the file flyout as a single list. Folders are not hoisted - * above the files beside them — the Files page sorts folders and files together, - * and a flyout that partitioned them would contradict the page it links into. + * Rail flyout body for a foldered workspace resource (Tables, Files). Every row + * is a link — the flyout is a jump list, so folders open as submenus rather than + * navigating, and an empty one has nowhere to go and is inert. */ -function fileFlyoutEntries( - folders: FileFolderFlyoutNode[], - files: WorkspaceFileRecord[] -): FileFlyoutEntry[] { - const entries: FileFlyoutEntry[] = [ - ...folders.map( - (folder): FileFlyoutEntry => ({ - kind: 'folder', - id: folder.id, - name: folder.name, - folder, - }) - ), - ...files.map( - (file): FileFlyoutEntry => ({ - kind: 'file', - id: file.id, - name: file.name, - file, - }) - ), - ] - return entries.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) +export function CollapsedResourceFlyout({ + entries, + icon, + currentItemId, + isLoading = false, + emptyLabel, +}: CollapsedResourceFlyoutProps) { + if (isLoading) { + return ( + + + Loading... + + ) + } + if (entries.length === 0) { + return {emptyLabel} + } + return } -const FILE_FLYOUT_ICON = ( -