From 6cdfd4df9ce7da63892326980304ca1c22748b03 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 28 Jul 2026 18:15:45 -0700 Subject: [PATCH] improvement(pinning): move pin into the context menu and make folders pinnable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replaces the hover-revealed inline pin button on Files, Knowledge, and Tables with a Pin/Unpin item in each row's right-click menu, labelled from current state - Deletes `PinButton` and the now-unused `ResourceCell.endAdornment` slot (plus the `group` class `DataRow` only carried for it) - Adds `folder` to `pinnedResourceTypeSchema` and `PINNED_RESOURCES` — no migration needed, `pinned_item.resource_type` is plain text — and wires Pin/Unpin into the Files folder rows, which sort pinned-first like every other list - Folder pins resolve against `workspace_file_folders`, not the generic `folder` table: file folders are still read and written there, and `folder`'s `resource_type = 'file'` rows are a one-time backfill new folders never reach --- apps/sim/app/api/pinned-items/route.test.ts | 17 +++++ .../components/resource/resource.tsx | 30 +-------- .../file-row-context-menu.tsx | 13 +++- .../workspace/[workspaceId]/files/files.tsx | 55 ++++++++++----- .../knowledge-base-context-menu.tsx | 15 ++++- .../[workspaceId]/knowledge/knowledge.tsx | 26 +++---- .../table-context-menu/table-context-menu.tsx | 18 +++-- .../workspace/[workspaceId]/tables/tables.tsx | 25 ++++--- apps/sim/components/pin-button.tsx | 67 ------------------- apps/sim/lib/api/contracts/pinned-items.ts | 8 ++- apps/sim/lib/pinned-items/resources.test.ts | 21 +++++- apps/sim/lib/pinned-items/resources.ts | 26 ++++++- 12 files changed, 172 insertions(+), 149 deletions(-) delete mode 100644 apps/sim/components/pin-button.tsx diff --git a/apps/sim/app/api/pinned-items/route.test.ts b/apps/sim/app/api/pinned-items/route.test.ts index 8a1f3e1c30f..a5e73f55a40 100644 --- a/apps/sim/app/api/pinned-items/route.test.ts +++ b/apps/sim/app/api/pinned-items/route.test.ts @@ -180,6 +180,23 @@ describe('Pinned Items API', () => { ) }) + it('accepts folder as a pinnable resourceType', async () => { + mockReturning.mockReturnValueOnce([ + { ...pinnedWorkflowRow, resourceType: 'folder', resourceId: 'folder-1' }, + ]) + + const response = await POST( + createMockRequest('POST', { ...body, resourceType: 'folder', resourceId: 'folder-1' }) + ) + + expect(response.status).toBe(201) + const data = await response.json() + expect(data.pinnedItem).toMatchObject({ resourceType: 'folder', resourceId: 'folder-1' }) + expect(mockValues).toHaveBeenCalledWith( + expect.objectContaining({ resourceType: 'folder', resourceId: 'folder-1' }) + ) + }) + it('returns 404 when the resource is not in the workspace', async () => { mockLimit.mockReturnValueOnce([]) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index b49cc89ff14..ed960210aa6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -58,15 +58,6 @@ export interface ResourceCell { * resting cell exactly (same gap, weight, icon size). */ editing?: ResourceCellEditing - /** - * Trailing node pinned to the far edge of the cell, after the label. Use this for - * per-row affordances (e.g. a pin toggle) so the canonical icon + truncating-label - * rendering is reused rather than re-derived in a `content` node. Suppressed while - * the cell is in rename mode, where the input owns the full width. - * - * The row sets `group`, so an adornment may use `group-hover:` to reveal itself. - */ - endAdornment?: ReactNode } export interface ResourceRow { @@ -497,16 +488,9 @@ interface CellContentProps { label: string content?: ReactNode editing?: ResourceCellEditing - endAdornment?: ReactNode } -const CellContent = memo(function CellContent({ - icon, - label, - content, - editing, - endAdornment, -}: CellContentProps) { +const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) { if (editing) { return ( @@ -522,19 +506,12 @@ const CellContent = memo(function CellContent({ ) } if (content) return <>{content} - const body = ( + return ( {icon && {icon}} ) - if (!endAdornment) return body - return ( - - {body} - {endAdornment} - - ) }) interface DataRowProps { @@ -661,7 +638,7 @@ const DataRow = memo(function DataRow({ data-resource-row data-row-id={row.id} className={cn( - 'group grid w-full transition-colors', + 'grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', onRowClick && 'cursor-pointer', @@ -704,7 +681,6 @@ const DataRow = memo(function DataRow({ label={cell?.label || EMPTY_CELL_PLACEHOLDER} content={cell?.content} editing={cell?.editing} - endAdornment={cell?.endAdornment} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index f7639dde029..9c660dc6f45 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -15,7 +15,7 @@ import { FolderInput, Pencil, } from '@sim/emcn' -import { Download, Link, Trash } from '@sim/emcn/icons' +import { Download, Link, Pin, Trash } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options' @@ -29,6 +29,9 @@ interface FileRowContextMenuProps { onDelete: () => void onMove?: (optionValue: string) => void onShare?: () => void + onTogglePin: () => void + /** Pin state of the right-clicked row, driving the Pin/Unpin label. */ + pinned: boolean moveOptions?: MoveOptionNode[] canEdit: boolean selectedCount: number @@ -44,6 +47,8 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ onDelete, onMove, onShare, + onTogglePin, + pinned, moveOptions, canEdit, selectedCount, @@ -78,6 +83,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ {isMultiSelect ? `Download ${selectedCount} items` : 'Download'} )} + {!isMultiSelect && ( + + + {pinned ? 'Unpin' : 'Pin'} + + )} {canEdit && ( <> diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index b39edf3d5bc..05d556330b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -25,7 +25,6 @@ import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { getDocumentIcon } from '@/components/icons/document-icons' -import { PinButton } from '@/components/pin-button' import { useLimitUpgradeToast } from '@/lib/billing/client' import { captureEvent } from '@/lib/posthog/client' import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download' @@ -85,7 +84,7 @@ import { } from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { usePinnedIds } from '@/hooks/queries/pinned-items' +import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useBulkArchiveWorkspaceFileItems, @@ -215,6 +214,10 @@ export function Files() { const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedFileIds = usePinnedIds(workspaceId, 'file') + // Folders pin under their own resource type, so their pinned set is a separate query. + const pinnedFolderIds = usePinnedIds(workspaceId, 'folder') + const pinItem = usePinItem() + const unpinItem = useUnpinItem() const membersById = useMemo(() => { const map = new Map() for (const member of members ?? []) map.set(member.userId, member) @@ -398,6 +401,12 @@ export function Files() { const col = activeSort?.column ?? 'name' const dir = activeSort?.direction ?? 'asc' return [...searched].sort((a, b) => { + // Pinned folders float to the top of every sort/direction — pinning is a + // user-declared priority, not another sort key to be inverted by `desc`. + const aPinned = pinnedFolderIds.has(a.id) + const bPinned = pinnedFolderIds.has(b.id) + if (aPinned !== bPinned) return aPinned ? -1 : 1 + let cmp = 0 if (col === 'updated') { cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() @@ -408,7 +417,7 @@ export function Files() { } return dir === 'asc' ? cmp : -cmp }) - }, [folders, currentFolderId, debouncedSearchTerm, activeSort]) + }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) const filteredFiles = useMemo(() => { const needle = debouncedSearchTerm.trim().toLowerCase() @@ -521,15 +530,6 @@ export function Files() { name: { icon: , label: file.name, - endAdornment: ( - - ), }, size: { label: formatFileSize(file.size, { includeBytes: true }), @@ -547,7 +547,7 @@ export function Files() { }) return [...folderRows, ...fileRows] - }, [visibleFolders, filteredFiles, membersById, folderSizeMap, workspaceId, pinnedFileIds]) + }, [visibleFolders, filteredFiles, membersById, folderSizeMap]) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows @@ -1365,6 +1365,17 @@ export function Files() { closeContextMenu() }, [selectedRowIds, handleBulkDelete, closeContextMenu]) + const handleContextMenuTogglePin = useCallback(() => { + const item = contextMenuItemRef.current + if (!item) return + const resourceType = item.kind === 'folder' ? 'folder' : 'file' + const pinned = + item.kind === 'folder' ? pinnedFolderIds.has(item.id) : pinnedFileIds.has(item.id) + const mutation = pinned ? unpinItem : pinItem + mutation.mutate({ workspaceId, resourceType, resourceId: item.id }) + closeContextMenu() + }, [workspaceId, pinnedFolderIds, pinnedFileIds, closeContextMenu]) + const handleContextMenuMove = useCallback( async (optionValue: string) => { const targetFolderId = optionValue === '__root__' ? null : optionValue @@ -1985,6 +1996,16 @@ export function Files() { ) } + /** + * Read off the same ref the context-menu handlers use, so the menu's Pin/Unpin label + * describes the row that was right-clicked. Opening the menu is a state change, so + * this re-reads on the render that shows it. + */ + const contextMenuItem = contextMenuItemRef.current + const isContextMenuItemPinned = contextMenuItem + ? (contextMenuItem.kind === 'folder' ? pinnedFolderIds : pinnedFileIds).has(contextMenuItem.id) + : false + return (
void onViewTags?: () => void onCopyId?: () => void + onTogglePin?: () => void + /** Pin state of the right-clicked base, driving the Pin/Unpin label. */ + pinned?: boolean onEdit?: () => void onDelete?: () => void showOpenInNewTab?: boolean @@ -38,6 +41,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ onOpenInNewTab, onViewTags, onCopyId, + onTogglePin, + pinned = false, onEdit, onDelete, showOpenInNewTab = true, @@ -48,7 +53,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ disableDelete = false, }: KnowledgeBaseContextMenuProps) { const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab - const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId + const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin const hasEditSection = showEdit && !!onEdit const hasDestructiveSection = showDelete && !!onDelete @@ -96,6 +101,12 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({ Copy ID )} + {onTogglePin && ( + + + {pinned ? 'Unpin' : 'Pin'} + + )} {hasInfoSection && (hasEditSection || hasDestructiveSection) && } {showEdit && onEdit && ( diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index f3cf22e24e2..32930a56128 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -7,7 +7,6 @@ import { Database } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PinButton } from '@/components/pin-button' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { @@ -44,7 +43,7 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' -import { usePinnedIds } from '@/hooks/queries/pinned-items' +import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' @@ -146,6 +145,8 @@ export function Knowledge() { const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base') + const pinItem = usePinItem() + const unpinItem = useUnpinItem() if (error) { logger.error('Failed to load knowledge bases:', error) @@ -349,15 +350,6 @@ export function Knowledge() { name: { icon: KNOWLEDGE_BASE_ICON, label: kb.name, - endAdornment: ( - - ), }, documents: { label: String(kbWithCount.docCount || 0), @@ -372,7 +364,7 @@ export function Knowledge() { }, } }), - [processedKBs, members, workspaceId, pinnedBaseIds] + [processedKBs, members] ) const handleRowClick = useCallback( @@ -437,6 +429,14 @@ export function Knowledge() { setIsEditModalOpen(true) }, []) + const handleTogglePin = useCallback(() => { + const kb = activeKnowledgeBaseRef.current + if (!kb) return + const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem + mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id }) + closeRowContextMenu() + }, [workspaceId, pinnedBaseIds, closeRowContextMenu]) + const handleDelete = useCallback(() => { setIsDeleteModalOpen(true) }, []) @@ -645,6 +645,8 @@ export function Knowledge() { onOpenInNewTab={handleOpenInNewTab} onViewTags={handleViewTags} onCopyId={handleCopyId} + onTogglePin={handleTogglePin} + pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} onDelete={handleDelete} showOpenInNewTab diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx index b8064ef6627..b0a97888a2f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/table-context-menu/table-context-menu.tsx @@ -8,13 +8,16 @@ import { DropdownMenuTrigger, Upload, } from '@sim/emcn' -import { Database, Download, Duplicate, Pencil, Trash } from '@sim/emcn/icons' +import { Database, Download, Duplicate, Pencil, Pin, Trash } from '@sim/emcn/icons' interface TableContextMenuProps { isOpen: boolean position: { x: number; y: number } onClose: () => void onCopyId?: () => void + onTogglePin?: () => void + /** Pin state of the right-clicked table, driving the Pin/Unpin label. */ + pinned?: boolean onDelete?: () => void onViewSchema?: () => void onRename?: () => void @@ -32,6 +35,8 @@ export function TableContextMenu({ position, onClose, onCopyId, + onTogglePin, + pinned = false, onDelete, onViewSchema, onRename, @@ -88,8 +93,13 @@ export function TableContextMenu({ Export CSV )} - {(onViewSchema || onRename || onImportCsv || onExportCsv) && (onCopyId || onDelete) && ( - + {(onViewSchema || onRename || onImportCsv || onExportCsv) && + (onCopyId || onTogglePin || onDelete) && } + {onTogglePin && ( + + + {pinned ? 'Unpin' : 'Pin'} + )} {onCopyId && ( @@ -97,7 +107,7 @@ export function TableContextMenu({ Copy ID )} - {onCopyId && onDelete && } + {(onCopyId || onTogglePin) && onDelete && } {onDelete && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 395fd7b6934..f2bd07ec6a4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -8,7 +8,6 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { PinButton } from '@/components/pin-button' import type { TableDefinition } from '@/lib/table' import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' @@ -34,7 +33,7 @@ import { tablesUrlKeys, } from '@/app/workspace/[workspaceId]/tables/search-params' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { usePinnedIds } from '@/hooks/queries/pinned-items' +import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { cancelTableJob, downloadTableExport, @@ -81,6 +80,8 @@ export function Tables() { const { data: tables = [], error } = useTablesList(workspaceId) const { data: members } = useWorkspaceMembersQuery(workspaceId) const pinnedTableIds = usePinnedIds(workspaceId, 'table') + const pinItem = usePinItem() + const unpinItem = useUnpinItem() if (error) { logger.error('Failed to load tables:', error) @@ -214,15 +215,6 @@ export function Tables() { name: { icon: , label: table.name, - endAdornment: ( - - ), editing: tableRename.editingId === table.id ? { @@ -249,8 +241,6 @@ export function Tables() { [ processedTables, members, - workspaceId, - pinnedTableIds, tableRename.editingId, tableRename.editValue, tableRename.setEditValue, @@ -446,6 +436,13 @@ export function Tables() { [tables, handleRowCtxMenu] ) + const handleTogglePin = useCallback(() => { + if (!activeTable) return + const mutation = pinnedTableIds.has(activeTable.id) ? unpinItem : pinItem + mutation.mutate({ workspaceId, resourceType: 'table', resourceId: activeTable.id }) + closeRowContextMenu() + }, [workspaceId, activeTable, pinnedTableIds, closeRowContextMenu]) + const handleDelete = async () => { if (!activeTable) return try { @@ -671,6 +668,8 @@ export function Tables() { onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} + onTogglePin={handleTogglePin} + pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} onDelete={() => setIsDeleteDialogOpen(true)} onRename={() => { if (activeTable) tableRename.startRename(activeTable.id, activeTable.name) diff --git a/apps/sim/components/pin-button.tsx b/apps/sim/components/pin-button.tsx deleted file mode 100644 index 84e8d5b016c..00000000000 --- a/apps/sim/components/pin-button.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client' - -import type { MouseEvent } from 'react' -import { cn } from '@sim/emcn' -import { Pin } from '@sim/emcn/icons' -import type { PinnedResourceType } from '@/lib/api/contracts' -import { usePinItem, useUnpinItem } from '@/hooks/queries/pinned-items' - -interface PinButtonProps { - workspaceId: string - resourceType: PinnedResourceType - resourceId: string - /** - * Whether this row is currently pinned. Passed in rather than read per-row so a - * list resolves pin state once via `usePinnedIds` instead of once per row. - */ - pinned: boolean - /** Layout/positioning only — the button owns its own chrome. */ - className?: string -} - -/** - * Row-level pin toggle, parameterized by resource type so workflows, files, - * knowledge bases, and tables all share one implementation. - * - * An unpinned button is revealed on row hover and a pinned one stays visible, so the - * containing row must set `group`. Keyboard users get it via `focus-visible`, which - * hover-reveal alone would strand. - */ -export function PinButton({ - workspaceId, - resourceType, - resourceId, - pinned, - className, -}: PinButtonProps) { - const pinItem = usePinItem() - const unpinItem = useUnpinItem() - - const handleClick = (event: MouseEvent) => { - // Rows are usually links or have their own onClick — pinning must not navigate. - event.preventDefault() - event.stopPropagation() - const mutation = pinned ? unpinItem : pinItem - mutation.mutate({ workspaceId, resourceType, resourceId }) - } - - return ( - - ) -} diff --git a/apps/sim/lib/api/contracts/pinned-items.ts b/apps/sim/lib/api/contracts/pinned-items.ts index 71f8a11e686..6d4434d9198 100644 --- a/apps/sim/lib/api/contracts/pinned-items.ts +++ b/apps/sim/lib/api/contracts/pinned-items.ts @@ -7,7 +7,13 @@ import { defineRouteContract } from '@/lib/api/contracts/types' * plain `text` on that table rather than a `pgEnum` — the set of pinnable kinds is * expected to grow, and this schema is the enforcement point. */ -export const pinnedResourceTypeSchema = z.enum(['workflow', 'file', 'knowledge_base', 'table']) +export const pinnedResourceTypeSchema = z.enum([ + 'workflow', + 'file', + 'knowledge_base', + 'table', + 'folder', +]) export type PinnedResourceType = z.output export const pinnedItemSchema = z.object({ diff --git a/apps/sim/lib/pinned-items/resources.test.ts b/apps/sim/lib/pinned-items/resources.test.ts index 8fff59ab1df..341d3f1df7d 100644 --- a/apps/sim/lib/pinned-items/resources.test.ts +++ b/apps/sim/lib/pinned-items/resources.test.ts @@ -93,15 +93,30 @@ describe('pinned-items resources', () => { }) it('drops pins with an unrecognized resourceType rather than surfacing them', async () => { - // Forward-compat: a pin written by a newer deploy (e.g. 'folder') must fail - // closed here rather than render against a table this build cannot resolve. + // Forward-compat: a pin written by a newer deploy must fail closed here rather + // than render against a table this build cannot resolve. const result = await filterToActiveResources( - [{ resourceType: 'folder', resourceId: 'folder-1' }], + [{ resourceType: 'not_a_pinnable_type', resourceId: 'x-1' }], 'ws-1' ) expect(result).toEqual([]) expect(mockDb.select).not.toHaveBeenCalled() }) + + it('resolves folder pins instead of failing them closed', async () => { + // Regression guard for the contract/lookup-map pair: adding 'folder' to + // `pinnedResourceTypeSchema` without a `PINNED_RESOURCES` entry would silently + // drop every folder pin here. + mockWhere.mockReturnValueOnce([{ id: 'folder-1' }]) + + const result = await filterToActiveResources( + [{ resourceType: 'folder', resourceId: 'folder-1' }], + 'ws-1' + ) + + expect(result).toEqual([{ resourceType: 'folder', resourceId: 'folder-1' }]) + expect(mockDb.select).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/apps/sim/lib/pinned-items/resources.ts b/apps/sim/lib/pinned-items/resources.ts index 6cc6d9b1e19..63340211ca9 100644 --- a/apps/sim/lib/pinned-items/resources.ts +++ b/apps/sim/lib/pinned-items/resources.ts @@ -1,4 +1,11 @@ -import { db, knowledgeBase, userTableDefinitions, workflow, workspaceFiles } from '@sim/db' +import { + db, + knowledgeBase, + userTableDefinitions, + workflow, + workspaceFileFolder, + workspaceFiles, +} from '@sim/db' import { and, eq, inArray, isNull, type SQL } from 'drizzle-orm' import type { PgColumn, PgTable } from 'drizzle-orm/pg-core' import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' @@ -47,6 +54,23 @@ const PINNED_RESOURCES: Record = { workspaceColumn: userTableDefinitions.workspaceId, deletedColumn: userTableDefinitions.archivedAt, }, + /** + * File folders are the only pinnable folders today, and they are still read and + * written against `workspace_file_folders` — the generic `folder` table currently + * backs workflow folders only, and its `resource_type = 'file'` rows are a one-time + * backfill that new file folders never reach. Resolving pins against `folder` would + * therefore drop every folder created after migration 0272. + * + * That backfill preserved folder ids, so when the file-folder cutover lands this + * entry repoints at `folder` (scoped to `resourceType = 'file'`) without invalidating + * a single existing pin. + */ + folder: { + table: workspaceFileFolder, + idColumn: workspaceFileFolder.id, + workspaceColumn: workspaceFileFolder.workspaceId, + deletedColumn: workspaceFileFolder.deletedAt, + }, } function activeResourceFilter(config: PinnedResourceConfig, workspaceId: string, ids: SQL): SQL {