From 4b0a24bafc16f1be1d68b8dad832c870ede4b113 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 19 Aug 2026 18:35:06 -0700 Subject: [PATCH 1/4] 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. --- .../components/folders/flyout-entries.test.ts | 139 ++++++++ .../components/folders/flyout-entries.ts | 102 ++++++ .../components/folders/foldered-resources.ts | 4 +- .../[workspaceId]/components/folders/index.ts | 2 + .../collapsed-sidebar-menu.test.tsx | 129 ++++++++ .../collapsed-sidebar-menu.tsx | 246 +++++++------- .../collapsed-sidebar-menu/index.ts | 2 +- .../components/file-list/file-list.tsx | 162 ---------- .../sidebar/components/file-list/index.ts | 1 - .../w/components/sidebar/components/index.ts | 6 +- .../components/rail-resource-flyout/index.ts | 1 + .../rail-resource-flyout.tsx | 89 ++++++ .../components/sidebar-nav-chip/index.ts | 2 + .../sidebar-nav-chip/sidebar-nav-chip.tsx | 84 +++++ .../w/components/sidebar/sidebar.tsx | 299 +++++++++--------- 15 files changed, 846 insertions(+), 422 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx 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..21e3add6d35 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts @@ -0,0 +1,139 @@ +/** + * @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', + children: [ + { + kind: 'folder', + id: 'f2', + name: 'Q1', + children: [{ kind: 'item', id: 'i1', name: 'Revenue', 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', href: '/x/i1' }]) + }) +}) 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..3ed6eb4bfe1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts @@ -0,0 +1,102 @@ +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; children: FlyoutEntry[] } + | { kind: 'item'; id: string; name: string; 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. + * + * 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) ?? []) { + rows.push({ + item: { kind: 'folder', id: folder.id, name: folder.name, children: buildLevel(folder.id) }, + pinned: pinnedFolderIds.has(folder.id), + name: folder.name, + key: flyoutSortTime(folder.updatedAt), + }) + } + for (const item of itemsByFolder.get(parentId) ?? []) { + rows.push({ + item: { kind: 'item', id: item.id, name: item.name, href: hrefForItem(item) }, + pinned: pinnedItemIds.has(item.id), + 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..052b94b680d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx @@ -0,0 +1,129 @@ +/** + * @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') + }) +}) 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..91a21b2d95e 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,103 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Loader, } from '@sim/emcn' -import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons' +import { Folder, MoreHorizontal, Pencil, 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 = ( -