diff --git a/apps/sim/.gitignore b/apps/sim/.gitignore index e90bb4dc00d..cd230779797 100644 --- a/apps/sim/.gitignore +++ b/apps/sim/.gitignore @@ -11,6 +11,10 @@ bun-debug.log* # testing /coverage +/e2e/.cache/ +/e2e/.runs/ +/playwright-report/ +/test-results/ # next.js /.next/ @@ -45,4 +49,4 @@ next-env.d.ts # Uploads /uploads -.trigger \ No newline at end of file +.trigger diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.test.tsx new file mode 100644 index 00000000000..a7249833a0f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.test.tsx @@ -0,0 +1,72 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const { tableMock } = vi.hoisted(() => ({ tableMock: vi.fn() })) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/table', () => ({ + Table: (props: Record) => { + tableMock(props) + return
+ }, +})) + +import { EmbeddedViewTable } from './embedded-view-table' + +let container: HTMLDivElement +let root: Root + +describe('EmbeddedViewTable', () => { + beforeEach(() => { + vi.clearAllMocks() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + }) + + it('renders the source Table with the persisted View selected', async () => { + await act(async () => { + root.render( + + ) + }) + + expect(container.querySelector('[data-testid="embedded-table"]')).not.toBeNull() + expect(tableMock).toHaveBeenCalledWith({ + workspaceId: 'workspace_1', + tableId: 'tbl_1', + viewId: 'view_1', + embedded: true, + viewsEnabled: true, + }) + }) + + it('does not render a malformed View resource', async () => { + await act(async () => { + root.render() + }) + + expect(container.innerHTML).toBe('') + expect(tableMock).not.toHaveBeenCalled() + }) + + it('does not bypass a disabled Views feature flag', async () => { + await act(async () => { + root.render() + }) + + expect(container.innerHTML).toBe('') + expect(tableMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.tsx new file mode 100644 index 00000000000..72310561a31 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/embedded-view-table.tsx @@ -0,0 +1,29 @@ +'use client' + +import { parseTableViewResourceId } from '@/lib/copilot/resources/types' +import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' + +interface EmbeddedViewTableProps { + workspaceId: string + resourceId: string + viewsEnabled?: boolean +} + +/** Renders a persisted View resource through its live source Table. */ +export function EmbeddedViewTable({ + workspaceId, + resourceId, + viewsEnabled = false, +}: EmbeddedViewTableProps) { + const parsed = parseTableViewResourceId(resourceId) + if (!parsed || !viewsEnabled) return null + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/index.ts new file mode 100644 index 00000000000..079dcb2cfb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table/index.ts @@ -0,0 +1 @@ +export { EmbeddedViewTable } from './embedded-view-table' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 0f5bd7849df..a5fb92942f5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -35,6 +35,7 @@ import { resolveFileCategory, } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session' +import { EmbeddedViewTable } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-view-table' import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content' import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session' import { @@ -237,6 +238,16 @@ export const ResourceContent = memo(function ResourceContent({ /> ) + case 'view': + return ( + + ) + case 'file': return ( ) + case 'view': + return null case 'log': return case 'scheduledtask': diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 1681f3a07b8..f81a805e354 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -7,6 +7,7 @@ import { Connections, Cursor, Database, + Eye, File as FileIcon, Folder as FolderIcon, Library, @@ -17,6 +18,7 @@ import { } from '@sim/emcn/icons' import type { QueryClient } from '@tanstack/react-query' import { getDocumentIcon } from '@/components/icons/document-icons' +import { parseTableViewResourceId } from '@/lib/copilot/resources/types' import type { MothershipResource, MothershipResourceType, @@ -140,6 +142,15 @@ export const RESOURCE_REGISTRY: Record , }, + view: { + type: 'view', + label: 'Views', + icon: Eye, + renderTabIcon: (_resource, className) => ( + + ), + renderDropdownItem: (props) => , + }, file: { type: 'file', label: 'Files', @@ -249,6 +260,12 @@ const RESOURCE_INVALIDATORS: Record< qc.invalidateQueries({ queryKey: tableKeys.lists() }) qc.invalidateQueries({ queryKey: tableKeys.detail(id) }) }, + view: (qc, _wId, id) => { + const parsed = parseTableViewResourceId(id) + if (!parsed) return + qc.invalidateQueries({ queryKey: tableKeys.detail(parsed.tableId) }) + qc.invalidateQueries({ queryKey: tableKeys.views(parsed.tableId) }) + }, file: (qc, wId, id) => { qc.invalidateQueries({ queryKey: workspaceFilesKeys.lists() }) qc.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(wId, id) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts index 29e4baa4ea7..db8581c0501 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts @@ -1,4 +1,5 @@ import { cn } from '@sim/emcn' +import { parseTableViewResourceId } from '@/lib/copilot/resources/types' import type { MothershipResource, MothershipResourceType, @@ -122,6 +123,11 @@ const RESOURCE_TO_CONTEXT: Record< workflow: (r) => ({ kind: 'workflow', workflowId: r.id, label: r.title }), knowledgebase: (r) => ({ kind: 'knowledge', knowledgeId: r.id, label: r.title }), table: (r) => ({ kind: 'table', tableId: r.id, label: r.title }), + view: (r) => ({ + kind: 'table', + tableId: parseTableViewResourceId(r.id)?.tableId ?? r.id, + label: r.title, + }), file: (r) => ({ kind: 'file', fileId: r.id, label: r.title }), folder: (r) => ({ kind: 'folder', folderId: r.id, label: r.title }), filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts index 9b45baa5c3f..1d96bb950f6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/resource-context.test.ts @@ -35,5 +35,12 @@ describe('mapResourceToContext', () => { tableId: 't-1', label: 'Leads', }) + expect( + mapResourceToContext(resource({ type: 'view', id: 't-1:v-1', title: 'Qualified leads' })) + ).toEqual({ + kind: 'table', + tableId: 't-1', + label: 'Qualified leads', + }) }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx new file mode 100644 index 00000000000..5ad09e3fad3 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.test.tsx @@ -0,0 +1,113 @@ +/** + * @vitest-environment jsdom + */ + +import type { ReactNode } from 'react' +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TableViewWire } from '@/lib/api/contracts/tables' + +;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +vi.mock('@sim/emcn', () => ({ + ChipChevronDown: () => null, + chipContentLabelClass: '', + chipVariants: () => '', + cn: (...values: unknown[]) => values.filter(Boolean).join(' '), + POPOVER_ANIMATION_CLASSES: '', + Popover: ({ children }: { children: ReactNode }) => <>{children}, + PopoverAnchor: ({ children }: { children: ReactNode }) => <>{children}, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( + + ), + PopoverSection: ({ children }: { children: ReactNode }) =>
{children}
, +})) + +import { ViewsMenu } from './views-menu' + +function view(id: string, name: string): TableViewWire { + return { + id, + tableId: 'tbl_1', + name, + config: {}, + isDefault: false, + createdBy: 'user_1', + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + } +} + +let container: HTMLDivElement +let root: Root + +describe('ViewsMenu', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + }) + + it('renders the implicit Default view first and selects saved Views', async () => { + const onSelect = vi.fn() + await act(async () => { + root.render( + + ) + }) + + const labels = Array.from(container.querySelectorAll('button')) + .map((button) => button.textContent?.trim()) + .filter(Boolean) + expect(labels.slice(1, 4)).toEqual(['Default view', 'Alpha', 'Zulu']) + + const alpha = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Alpha' + ) + await act(async () => alpha?.click()) + expect(onSelect).toHaveBeenCalledWith('view_a') + }) + + it('exposes rename and delete actions only for saved Views', async () => { + const onRename = vi.fn() + const onDelete = vi.fn() + await act(async () => { + root.render( + + ) + }) + + const rename = container.querySelector('button[aria-label="Rename"]') + const remove = container.querySelector('button[aria-label="Delete"]') + expect(rename).not.toBeNull() + expect(remove).not.toBeNull() + + await act(async () => rename?.click()) + await act(async () => remove?.click()) + expect(onRename).toHaveBeenCalledWith('view_a') + expect(onDelete).toHaveBeenCalledWith('view_a') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx index 08a3643653d..b22d592684e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/views-menu/views-menu.tsx @@ -17,7 +17,7 @@ import { Check, Pencil, Plus, Trash } from '@sim/emcn/icons' import type { TableViewWire } from '@/lib/api/contracts/tables' /** Label for the built-in unfiltered state. Not a stored row — `null` view id. */ -export const ALL_ROWS_VIEW_LABEL = 'All' +export const ALL_ROWS_VIEW_LABEL = 'Default view' /** Matches the breadcrumb location popover's hover-intent grace period. */ const POPOVER_CLOSE_DELAY_MS = 120 @@ -60,7 +60,7 @@ export const ViewsMenu = memo(function ViewsMenu({ const closeTimeoutRef = useRef | null>(null) const activeView = activeViewId ? views.find((view) => view.id === activeViewId) : undefined - const label = activeView?.name ?? 'View' + const label = activeView?.name ?? ALL_ROWS_VIEW_LABEL const cancelScheduledClose = () => { if (closeTimeoutRef.current) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 9fcf4dd7db5..2a5b4f93e3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -10,7 +10,10 @@ import type { WorkflowGroup, } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' -import { pruneFilterForColumns } from '@/lib/table/query-builder/converters' +import { + pruneFilterForColumns, + pruneViewFilterForColumns, +} from '@/lib/table/query-builder/converters' import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs' import { getBlock } from '@/blocks' import { @@ -97,10 +100,11 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) // server rejects outright, which would fail every subsequent rows query. Prune // here, above every consumer of the rows query key, so the paged helpers below // can't rebuild the key from the unpruned filter and drift. - const filter = useMemo( - () => pruneFilterForColumns(queryOptions.filter ?? null, tableData?.schema?.columns ?? []), - [queryOptions.filter, tableData?.schema?.columns] - ) + const filter = useMemo(() => { + const columns = tableData?.schema?.columns ?? [] + const compatible = pruneFilterForColumns(queryOptions.filter ?? null, columns) + return columns.length > 0 ? pruneViewFilterForColumns(compatible, columns) : compatible + }, [queryOptions.filter, tableData?.schema?.columns]) const { data: rowsData, diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 0540b5346b5..6db5e685383 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -22,6 +22,10 @@ import type { } from '@/lib/table' import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS } from '@/lib/table/constants' +import { + pruneFilterForColumns, + pruneViewFilterForColumns, +} from '@/lib/table/query-builder/converters' import { type BreadcrumbItem, type ColumnOption, @@ -95,6 +99,8 @@ interface TableProps { /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ workspaceId?: string tableId?: string + /** View selected when the table is opened as a persisted chat resource. */ + viewId?: string /** * Whether an admin may CHANGE locks, resolved server-side by the page (the * flag's gating lives in AppConfig and has no client counterpart). Defaults @@ -208,6 +214,7 @@ export function Table({ embedded, workspaceId: propWorkspaceId, tableId: propTableId, + viewId: propViewId, tableLocksEnabled = false, viewsEnabled = false, }: TableProps = {}) { @@ -261,9 +268,36 @@ export function Table({ * panel's Columns section edits it and the active view persists it. */ const [hiddenColumns, setHiddenColumns] = useState([]) - const [{ sort: sortColumn, dir: sortDirection, view: activeViewId }, setTableParams] = + const [{ sort: sortColumn, dir: sortDirection, view: urlActiveViewId }, setTableParams] = useQueryStates(tableDetailParsers, tableDetailUrlKeys) + /** + * An embedded View must own the first render synchronously. Seeding only the + * host URL in an effect races the view resolver: it can adopt Default view + * before the URL write lands and clear the requested chat resource. + * + * Keep a local selection only for View-resource embeds; page Tables and plain + * Table resources continue to use the URL as their sole source of truth. + */ + const [embeddedActiveViewId, setEmbeddedActiveViewId] = useState( + () => propViewId ?? null + ) + const activeViewId = propViewId ? embeddedActiveViewId : urlActiveViewId + const syncedPropViewRef = useRef(undefined) + useEffect(() => { + if (!propViewId || syncedPropViewRef.current === propViewId) return + syncedPropViewRef.current = propViewId + setEmbeddedActiveViewId(propViewId) + void setTableParams({ view: propViewId }) + }, [propViewId, setTableParams]) + const setActiveViewId = useCallback( + (viewId: string) => { + if (propViewId) setEmbeddedActiveViewId(viewId) + void setTableParams({ view: viewId }) + }, + [propViewId, setTableParams] + ) + // Read-only mirrors for the resolve effect: it must know whether the user has // already applied a filter / hidden columns without re-running when they change. const filterRef = useRef(filter) @@ -528,6 +562,7 @@ export function Table({ // switching resources away and back, not leakage. const inheritedParams = embedded && + !propViewId && activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !views.some((view) => view.id === activeViewId) @@ -540,7 +575,7 @@ export function Table({ const keep = inheritedParams ? { ...localWork(), sort: false } : localWork() if (defaultView) { seededViewIdRef.current = defaultView.id - setTableParams({ view: defaultView.id }) + setActiveViewId(defaultView.id) applyViewConfig(defaultView.config, keep) resolvePendingLayout(true) return @@ -549,7 +584,10 @@ export function Table({ // would clear a deep-linked `?sort=` on mount. Inherited params are the // exception: nothing about them refers to this table, so they're cleared. seededViewIdRef.current = null - if (inheritedParams) setTableParams({ view: ALL_VIEW_PARAM, sort: null, dir: null }) + if (inheritedParams) { + setActiveViewId(ALL_VIEW_PARAM) + setTableParams({ sort: null, dir: null }) + } resolvePendingLayout(false) return } @@ -559,8 +597,9 @@ export function Table({ return } // A `?view=` that resolves to nothing (deleted view, stale bookmark) falls - // back to "All" without touching state, for the same reason. An explicit - // `?sort=` alongside `?view=` also wins over the view's stored sort. + // back to "All". An explicit `?sort=` alongside a page `?view=` still wins, + // but an embedded View resource must clear the host page's inherited state: + // none of it belongs to the missing View the resource requested. seededViewIdRef.current = activeView?.id ?? null resolvePendingLayout(activeView !== null) if (activeView) { @@ -569,7 +608,8 @@ export function Table({ // Nothing to apply, but the URL still names a view that no longer exists. // Rewrite it so a stale bookmark can't be copied on, and so the param // matches the All the UI is already showing. - setTableParams({ view: ALL_VIEW_PARAM }) + setActiveViewId(ALL_VIEW_PARAM) + if (propViewId) applyViewConfig(null) } return } @@ -587,7 +627,7 @@ export function Table({ if (activeViewId !== null && activeViewId !== ALL_VIEW_PARAM && !activeView) { if (pendingCreatedViewIdRef.current === activeViewId) return seededViewIdRef.current = null - setTableParams({ view: ALL_VIEW_PARAM }) + setActiveViewId(ALL_VIEW_PARAM) applyViewConfig(null) return } @@ -609,9 +649,10 @@ export function Table({ activeView, activeViewId, embedded, + propViewId, sortColumn, applyViewConfig, - setTableParams, + setActiveViewId, resolvePendingLayout, ]) @@ -670,6 +711,7 @@ export function Table({ if (columns.length === 0) return stored return { ...stored, + filter: pruneViewFilterForColumns(stored.filter ?? null, columns), hiddenColumns: (stored.hiddenColumns ?? []).filter((id) => liveColumnIds.has(id)), sort: stored.sort && Object.keys(stored.sort).every((id) => liveColumnIds.has(id)) @@ -679,9 +721,10 @@ export function Table({ }, [activeView, columns.length, liveColumnIds]) /** - * Whether the live state diverges from what the active view stores (or, on - * "All", whether anything is applied at all). Drives the Save button — it is - * the only affordance that persists, so ad-hoc exploration stays throwaway. + * Whether live state diverges from the active View (or, on Default view, + * whether anything is applied). Filter/sort auto-save while a View is active; + * this still drives Save for Default-view creation, hidden-column edits, and + * the short interval before an auto-save response updates the cache. */ const isViewDirty = storedViewConfig ? !isSameViewConfig(currentViewConfig, storedViewConfig) @@ -694,9 +737,9 @@ export function Table({ const handleSelectView = useCallback( (viewId: string | null) => { - setTableParams({ view: viewId ?? ALL_VIEW_PARAM }) + setActiveViewId(viewId ?? ALL_VIEW_PARAM) }, - [setTableParams] + [setActiveViewId] ) const handleRenameView = useCallback((viewId: string) => { @@ -803,7 +846,7 @@ export function Table({ // seeded — it can't tell a just-created view from a dead id otherwise. seededViewIdRef.current = view.id pendingCreatedViewIdRef.current = view.id - setTableParams({ view: view.id }) + setActiveViewId(view.id) // Which means the blank config must be applied here; nuqs batches this // sort write with the `view` write above into one URL update. if (blank) applyViewConfig(view.config) @@ -817,12 +860,12 @@ export function Table({ (viewId: string) => { deleteViewMutation.mutate(viewId, { onSuccess: () => { - if (viewId === activeViewId) setTableParams({ view: ALL_VIEW_PARAM }) + if (viewId === activeViewId) setActiveViewId(ALL_VIEW_PARAM) }, onError: (error) => toast.error(getErrorMessage(error, 'Failed to delete view')), }) }, - [activeViewId, setTableParams] + [activeViewId, setActiveViewId] ) const runColumnMutation = useRunColumn({ workspaceId, tableId }) @@ -1051,18 +1094,49 @@ export function Table({ () => ({ options: columnOptions, active: sortColumn ? { column: sortColumn, direction: sortDirection } : null, - onSort: (column, direction) => setTableParams({ sort: column, dir: direction }), + onSort: (column, direction) => { + setTableParams({ sort: column, dir: direction }) + if (activeView && userPermissions.canEdit) { + updateViewMutation.mutate( + { viewId: activeView.id, configPatch: { sort: { [column]: direction } } }, + { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save View sort')) } + ) + } + }, /** * Clearing writes the default direction (stripped by clearOnDefault) and * drops the column, leaving a clean URL with no active sort. */ - onClear: () => setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }), + onClear: () => { + setTableParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }) + if (activeView && userPermissions.canEdit) { + updateViewMutation.mutate( + { viewId: activeView.id, configPatch: { sort: null } }, + { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save View sort')) } + ) + } + }, }), - [columnOptions, sortColumn, sortDirection, setTableParams] + [ + columnOptions, + sortColumn, + sortDirection, + setTableParams, + activeView, + userPermissions.canEdit, + updateViewMutation, + ] ) const handleFilterApply = (next: Filter | null) => { - setFilter(next) + const canonical = pruneViewFilterForColumns(pruneFilterForColumns(next, columns), columns) + setFilter(canonical) + if (activeView && userPermissions.canEdit) { + updateViewMutation.mutate( + { viewId: activeView.id, configPatch: { filter: canonical } }, + { onError: (error) => toast.error(getErrorMessage(error, 'Failed to save View filter')) } + ) + } } const breadcrumbs = useMemo( diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 88159f0d49b..df33b591715 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -3337,7 +3337,7 @@ export const OpenResource: ToolCatalogEntry = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: ['workflow', 'table', 'view', 'knowledgebase', 'file', 'log', 'scheduledtask'], }, }, required: ['type'], @@ -4646,7 +4646,7 @@ export const UserTable: ToolCatalogEntry = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter, and create_view', }, groupId: { type: 'string', @@ -4725,7 +4725,7 @@ export const UserTable: ToolCatalogEntry = { name: { type: 'string', description: - "Table name (required for 'create'). Also the optional display name for add_enrichment — defaults to the enrichment's registry name when omitted.", + "Table name (required for 'create'), optional View name for 'create_view' (generated from the Table name when omitted), or optional display name for add_enrichment.", }, newName: { type: 'string', @@ -4735,7 +4735,7 @@ export const UserTable: ToolCatalogEntry = { newType: { type: 'string', description: - "New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.", + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, offset: { type: 'number', @@ -4744,7 +4744,7 @@ export const UserTable: ToolCatalogEntry = { options: { type: 'array', description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.', + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string' }, }, outputColumnNames: { @@ -4834,7 +4834,7 @@ export const UserTable: ToolCatalogEntry = { sort: { type: 'object', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows and create_view)", }, tableId: { type: 'string', @@ -4869,7 +4869,8 @@ export const UserTable: ToolCatalogEntry = { }, operation: { type: 'string', - description: 'The operation to perform', + description: + 'The operation to perform. Use create_view only when the user explicitly asks to show or open a filtered/sorted Table; use query_rows for ordinary analysis without creating a durable View.', enum: [ 'create', 'create_from_file', @@ -4901,6 +4902,7 @@ export const UserTable: ToolCatalogEntry = { 'list_workflow_outputs', 'list_enrichments', 'add_enrichment', + 'create_view', ], }, }, @@ -5354,6 +5356,7 @@ export const UserTableOperation = { listWorkflowOutputs: 'list_workflow_outputs', listEnrichments: 'list_enrichments', addEnrichment: 'add_enrichment', + createView: 'create_view', } as const export type UserTableOperation = (typeof UserTableOperation)[keyof typeof UserTableOperation] @@ -5389,6 +5392,7 @@ export const UserTableOperationValues = [ UserTableOperation.listWorkflowOutputs, UserTableOperation.listEnrichments, UserTableOperation.addEnrichment, + UserTableOperation.createView, ] as const export const WorkspaceFileOperation = { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index e527c57b544..5e1ab773bf3 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2999,7 +2999,15 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: { type: 'string', description: 'The resource type.', - enum: ['workflow', 'table', 'knowledgebase', 'file', 'log', 'scheduledtask'], + enum: [ + 'workflow', + 'table', + 'view', + 'knowledgebase', + 'file', + 'log', + 'scheduledtask', + ], }, }, required: ['type'], @@ -4289,7 +4297,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { filter: { type: 'object', description: - 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', + 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter, and create_view', }, groupId: { type: 'string', @@ -4376,7 +4384,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { name: { type: 'string', description: - "Table name (required for 'create'). Also the optional display name for add_enrichment — defaults to the enrichment's registry name when omitted.", + "Table name (required for 'create'), optional View name for 'create_view' (generated from the Table name when omitted), or optional display name for add_enrichment.", }, newName: { type: 'string', @@ -4386,7 +4394,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { newType: { type: 'string', description: - "New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn't match one of them.", + 'New column type (optional for update_column). Types: string, number, boolean, date, json, select. Converting a column to select also requires options; the conversion fails if any existing cell value doesn\'t match one of them. Converting to a multiple: true select also accepts a comma-separated cell ("Open, Urgent"), which is the form a multi column converts to text as — so multiselect → text → multiselect round-trips.', }, offset: { type: 'number', @@ -4395,7 +4403,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { options: { type: 'array', description: - 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list: options kept by name keep their cells, and cells holding a removed option are cleared. Max 100.', + 'Choices for a select (enum) column, as a list of display names, e.g. ["Open", "Closed"]. Required when creating or converting to a select column. On update_column this REPLACES the option list and is matched against the current one BY NAME: a name still present keeps its cells, a name no longer present is removed and cleared from every cell that held it. Send the full list including the options you are keeping — omitting one deletes it. There is no in-place rename, so re-sending an option under a new name clears the cells that held the old one. Max 100.', items: { type: 'string', }, @@ -4495,7 +4503,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { sort: { type: 'object', description: - "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows)", + "Sort specification as { field: 'asc' | 'desc' } (optional for query_rows and create_view)", }, tableId: { type: 'string', @@ -4532,7 +4540,8 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, operation: { type: 'string', - description: 'The operation to perform', + description: + 'The operation to perform. Use create_view only when the user explicitly asks to show or open a filtered/sorted Table; use query_rows for ordinary analysis without creating a durable View.', enum: [ 'create', 'create_from_file', @@ -4564,6 +4573,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'list_workflow_outputs', 'list_enrichments', 'add_enrichment', + 'create_view', ], }, }, diff --git a/apps/sim/lib/copilot/resource-types.ts b/apps/sim/lib/copilot/resource-types.ts index a223536925c..2e8220f3c48 100644 --- a/apps/sim/lib/copilot/resource-types.ts +++ b/apps/sim/lib/copilot/resource-types.ts @@ -1,5 +1,6 @@ export type MothershipResourceType = | 'table' + | 'view' | 'file' | 'workflow' | 'knowledgebase' diff --git a/apps/sim/lib/copilot/resources/extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts index 150c8b217b7..29503c56037 100644 --- a/apps/sim/lib/copilot/resources/extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -5,6 +5,44 @@ import { describe, expect, it } from 'vitest' import { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult } from './extraction' describe('extractResourcesFromToolResult', () => { + it('auto-opens the created View instead of its source Table', () => { + const resources = extractResourcesFromToolResult( + 'user_table', + { + operation: 'create_view', + args: { tableId: 'tbl_123' }, + }, + { + success: true, + message: 'Created View "Qualified leads"', + data: { + view: { + id: 'view_456', + tableId: 'tbl_123', + name: 'Qualified leads', + }, + }, + // Server tools are wrapped in ToolExecutionResult.output, so this + // nested descriptor is not available as result.resources. + resources: [ + { + type: 'view', + id: 'tbl_123:view_456', + title: 'Qualified leads', + }, + ], + } + ) + + expect(resources).toEqual([ + { + type: 'view', + id: 'tbl_123:view_456', + title: 'Qualified leads', + }, + ]) + }) + it('extracts file resources from create_file results', () => { const resources = extractResourcesFromToolResult( 'create_file', diff --git a/apps/sim/lib/copilot/resources/extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts index ddd7b4f52db..1ea07a21d8f 100644 --- a/apps/sim/lib/copilot/resources/extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -15,7 +15,7 @@ import { UserTable, WorkspaceFile, } from '@/lib/copilot/generated/tool-catalog-v1' -import type { MothershipResource, MothershipResourceType } from './types' +import { type MothershipResource, type MothershipResourceType, tableViewResourceId } from './types' type ChatResource = MothershipResource type ResourceType = MothershipResourceType @@ -79,6 +79,22 @@ export function extractResourcesFromToolResult( case UserTable.id: { if (READ_ONLY_TABLE_OPS.has(getOperation(params) ?? '')) return [] + const args = asRecord(params?.args) + if (getOperation(params) === 'create_view') { + const view = asRecord(data.view) + const tableId = (view.tableId as string | undefined) ?? (args.tableId as string | undefined) + if (tableId && view.id) { + return [ + { + type: 'view', + id: tableViewResourceId(tableId, view.id as string), + title: (view.name as string) || 'View', + }, + ] + } + return [] + } + if (result.tableId) { return [ { @@ -101,7 +117,6 @@ export function extractResourcesFromToolResult( if (table.id) { return [{ type: 'table', id: table.id as string, title: (table.name as string) || 'Table' }] } - const args = asRecord(params?.args) const tableId = (data.tableId as string) ?? (args.tableId as string) ?? (params?.tableId as string) if (tableId) { diff --git a/apps/sim/lib/copilot/resources/types.test.ts b/apps/sim/lib/copilot/resources/types.test.ts index 928198d7747..435bbe83bd4 100644 --- a/apps/sim/lib/copilot/resources/types.test.ts +++ b/apps/sim/lib/copilot/resources/types.test.ts @@ -7,7 +7,9 @@ import { type MothershipResource, MothershipResourceType, PERSISTED_RESOURCE_TYPES, + parseTableViewResourceId, TERMINAL_SESSION_RESOURCE_ID, + tableViewResourceId, } from './types' function resource(overrides: Partial = {}): MothershipResource { @@ -38,6 +40,23 @@ describe('isEphemeralResource', () => { }) }) +describe('View resource ids', () => { + it('round-trips the source Table and View ids', () => { + const resourceId = tableViewResourceId('tbl_1', 'view_1') + expect(resourceId).toBe('tbl_1:view_1') + expect(parseTableViewResourceId(resourceId)).toEqual({ + tableId: 'tbl_1', + viewId: 'view_1', + }) + }) + + it('rejects malformed persisted View resource ids', () => { + expect(parseTableViewResourceId('view_1')).toBeNull() + expect(parseTableViewResourceId(':view_1')).toBeNull() + expect(parseTableViewResourceId('tbl_1:')).toBeNull() + }) +}) + describe('isDesktopOnlyResource', () => { it('marks the panels that need the desktop bridge', () => { expect(isDesktopOnlyResource(resource({ type: 'browser' }))).toBe(true) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts index 09c03370d99..02f60d1bbec 100644 --- a/apps/sim/lib/copilot/resources/types.ts +++ b/apps/sim/lib/copilot/resources/types.ts @@ -1,5 +1,6 @@ export const MothershipResourceType = { table: 'table', + view: 'view', file: 'file', workflow: 'workflow', knowledgebase: 'knowledgebase', @@ -48,6 +49,7 @@ interface ResourcePolicy { */ const RESOURCE_POLICY: Record = { table: { persisted: true }, + view: { persisted: true }, file: { persisted: true }, workflow: { persisted: true }, knowledgebase: { persisted: true }, @@ -108,6 +110,7 @@ export const TERMINAL_SESSION_RESOURCE_ID = 'terminal-session' /** Placeholder resource titles that a more specific title may overwrite during dedup. */ export const GENERIC_RESOURCE_TITLES = new Set([ 'Table', + 'View', 'File', 'Workflow', 'Knowledge Base', @@ -116,6 +119,23 @@ export const GENERIC_RESOURCE_TITLES = new Set([ 'Log', ]) +/** Encodes a View and its source Table into the opaque id persisted with a chat. */ +export function tableViewResourceId(tableId: string, viewId: string): string { + return `${tableId}:${viewId}` +} + +/** Decodes a persisted View resource without expanding the chat resource wire format. */ +export function parseTableViewResourceId( + resourceId: string +): { tableId: string; viewId: string } | null { + const separator = resourceId.indexOf(':') + if (separator <= 0 || separator === resourceId.length - 1) return null + return { + tableId: resourceId.slice(0, separator), + viewId: resourceId.slice(separator + 1), + } +} + export const VFS_DIR_TO_RESOURCE: Record = { tables: 'table', files: 'file', diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 8e69e1dce8f..5ec83527b05 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,7 +4,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ +const { + getTableByIdMock, + getTableViewMock, + getWorkspaceFileMock, + resolveWorkspaceFileReferenceMock, +} = vi.hoisted(() => ({ + getTableByIdMock: vi.fn(), + getTableViewMock: vi.fn(), getWorkspaceFileMock: vi.fn(), resolveWorkspaceFileReferenceMock: vi.fn(), })) @@ -19,7 +26,11 @@ vi.mock('@/lib/workflows/utils', () => ({ })) vi.mock('@/lib/table/service', () => ({ - getTableById: vi.fn(), + getTableById: getTableByIdMock, +})) + +vi.mock('@/lib/table/views/service', () => ({ + getTableView: getTableViewMock, })) vi.mock('@/lib/knowledge/service', () => ({ @@ -97,4 +108,97 @@ describe('executeOpenResource', () => { ], }) }) + + it('opens a View as a durable resource backed by its source Table', async () => { + getTableViewMock.mockResolvedValue({ + id: 'view_1', + tableId: 'tbl_1', + name: 'Qualified leads', + }) + getTableByIdMock.mockResolvedValue({ + id: 'tbl_1', + workspaceId: 'workspace-1', + name: 'Leads', + }) + + const result = await executeOpenResource( + { resources: [{ type: 'view', id: 'view_1' }] }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(result).toMatchObject({ + success: true, + output: { opened: 1, errors: [] }, + resources: [{ type: 'view', id: 'tbl_1:view_1', title: 'Qualified leads' }], + }) + }) + + it('reopens a View from its persisted composite resource id', async () => { + getTableViewMock.mockResolvedValue({ + id: 'view_1', + tableId: 'tbl_1', + name: 'Qualified leads', + }) + getTableByIdMock.mockResolvedValue({ + id: 'tbl_1', + workspaceId: 'workspace-1', + name: 'Leads', + }) + + const result = await executeOpenResource( + { resources: [{ type: 'view', id: 'tbl_1:view_1' }] }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(getTableViewMock).toHaveBeenCalledWith('view_1') + expect(result).toMatchObject({ + success: true, + output: { opened: 1, errors: [] }, + resources: [{ type: 'view', id: 'tbl_1:view_1', title: 'Qualified leads' }], + }) + }) + + it('refuses a composite View id with the wrong source Table', async () => { + getTableViewMock.mockResolvedValue({ + id: 'view_1', + tableId: 'tbl_actual', + name: 'Qualified leads', + }) + + const result = await executeOpenResource( + { resources: [{ type: 'view', id: 'tbl_claimed:view_1' }] }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(getTableByIdMock).not.toHaveBeenCalled() + expect(result).toMatchObject({ + success: false, + output: { opened: 0, errors: ['View does not belong to the specified Table.'] }, + resources: [], + }) + }) + + it('refuses a View whose source Table belongs to another workspace', async () => { + getTableViewMock.mockResolvedValue({ + id: 'view_1', + tableId: 'tbl_1', + name: 'Unsafe View', + }) + getTableByIdMock.mockResolvedValue({ + id: 'tbl_1', + workspaceId: 'workspace-2', + name: 'Private Table', + }) + + const result = await executeOpenResource( + { resources: [{ type: 'view', id: 'view_1' }] }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + ) + + expect(result).toMatchObject({ + success: false, + output: { opened: 0, errors: ['View not found in the current workspace.'] }, + resources: [], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index e7a970f3992..66cfa131660 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -2,11 +2,17 @@ import { db } from '@sim/db' import { workflowSchedule } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' +import { + type MothershipResource, + MothershipResourceType, + parseTableViewResourceId, + tableViewResourceId, +} from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' +import { getTableView } from '@/lib/table/views/service' import { getWorkspaceFile, resolveWorkspaceFileReference, @@ -61,6 +67,21 @@ async function resolveResource( resourceId = tbl.id title = tbl.name } + if (resourceType === 'view') { + if (!item.id) return { error: 'view resources require `id`.' } + const parsedId = parseTableViewResourceId(item.id) + const view = await getTableView(parsedId?.viewId ?? item.id) + if (!view) return { error: `No View with id "${item.id}".` } + if (parsedId && parsedId.tableId !== view.tableId) { + return { error: 'View does not belong to the specified Table.' } + } + const table = await getTableById(view.tableId) + if (!table || (context.workspaceId && table.workspaceId !== context.workspaceId)) { + return { error: 'View not found in the current workspace.' } + } + resourceId = tableViewResourceId(table.id, view.id) + title = view.name + } if (resourceType === 'knowledgebase') { if (!item.id) return { error: 'knowledgebase resources require `id`.' } const kb = await getKnowledgeBaseById(item.id) diff --git a/apps/sim/lib/copilot/tools/server/router.test.ts b/apps/sim/lib/copilot/tools/server/router.test.ts new file mode 100644 index 00000000000..720ec4b5fac --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/router.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import { routeExecution } from '@/lib/copilot/tools/server/router' + +describe('server tool write gating', () => { + it('denies create_view before executing for a read-only workspace member', async () => { + await expect( + routeExecution( + UserTable.id, + { + operation: 'create_view', + args: { tableId: 'tbl_1', name: 'Private View' }, + }, + { + userId: 'user_1', + workspaceId: 'workspace_1', + userPermission: 'read', + } + ) + ).rejects.toThrow("Permission denied: 'create_view' on user_table requires write access") + }) +}) diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 266c967bf57..628336134b9 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -116,6 +116,7 @@ const WRITE_ACTIONS: Record = { 'delete_column', 'update_column', 'add_enrichment', + 'create_view', ], [ManageCustomTool.id]: ['add', 'edit', 'delete'], [ManageMcpTool.id]: ['add', 'edit', 'delete'], diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts index b0dd887cb49..59724ea10c3 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.test.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.test.ts @@ -25,6 +25,8 @@ const { mockRunTableImport, mockRunTableDelete, mockRunTableUpdate, + mockCreateTableView, + mockListTableViews, fakeEnrichment, } = vi.hoisted(() => ({ mockUpdateColumnType: vi.fn(), @@ -46,6 +48,8 @@ const { mockRunTableImport: vi.fn(), mockRunTableDelete: vi.fn(), mockRunTableUpdate: vi.fn(), + mockCreateTableView: vi.fn(), + mockListTableViews: vi.fn(), fakeEnrichment: { id: 'work-email', name: 'Work Email', @@ -137,6 +141,11 @@ vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetWorkspaceTableLimits, })) +vi.mock('@/lib/table/views/service', () => ({ + createTableView: mockCreateTableView, + listTableViews: mockListTableViews, +})) + import { normalizeSelectOptionsInput, userTableServerTool, @@ -808,6 +817,137 @@ describe('userTableServerTool.query_rows', () => { }) }) +describe('userTableServerTool.create_view', () => { + const view = { + id: 'view_1', + tableId: 'tbl_1', + name: 'Open people', + config: { filter: { col_status: 'opt_open' }, sort: { col_name: 'desc' } }, + isDefault: false, + createdBy: 'user-1', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + } + + beforeEach(() => { + vi.clearAllMocks() + mockGetTableById.mockResolvedValue( + buildTable({ + schema: { + columns: [ + { id: 'col_name', name: 'Name', type: 'string' }, + { + id: 'col_status', + name: 'Status', + type: 'select', + options: [ + { id: 'opt_open', name: 'Open' }, + { id: 'opt_closed', name: 'Closed' }, + ], + }, + ], + }, + }) + ) + mockListTableViews.mockResolvedValue([]) + mockCreateTableView.mockResolvedValue(view) + }) + + it('converts column and select names to stable ids and opens the created View', async () => { + const result = await userTableServerTool.execute( + { + operation: 'create_view', + args: { + tableId: 'tbl_1', + name: 'Open people', + filter: { Status: 'Open' }, + sort: { Name: 'desc' }, + }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(true) + expect(mockCreateTableView).toHaveBeenCalledWith({ + tableId: 'tbl_1', + workspaceId: 'workspace-1', + name: 'Open people', + config: { + filter: { col_status: 'opt_open' }, + sort: { col_name: 'desc' }, + }, + userId: 'user-1', + columns: expect.any(Array), + }) + expect(result.resources).toEqual([{ type: 'view', id: 'tbl_1:view_1', title: 'Open people' }]) + }) + + it('generates a collision-free View name when one is not supplied', async () => { + mockListTableViews.mockResolvedValue([ + { ...view, id: 'view_existing', name: 'People View' }, + { ...view, id: 'view_existing_2', name: 'People View 2' }, + ]) + mockCreateTableView.mockImplementation(async (input) => ({ ...view, name: input.name })) + + const result = await userTableServerTool.execute( + { operation: 'create_view', args: { tableId: 'tbl_1' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(true) + expect(mockCreateTableView).toHaveBeenCalledWith( + expect.objectContaining({ name: 'People View 3' }) + ) + }) + + it('normalizes an empty model-authored filter to no filter', async () => { + await userTableServerTool.execute( + { operation: 'create_view', args: { tableId: 'tbl_1', filter: {} } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(mockCreateTableView).toHaveBeenCalledWith( + expect.objectContaining({ config: { filter: null, sort: null } }) + ) + }) + + it('rejects a Table from another workspace without creating a View', async () => { + mockGetTableById.mockResolvedValue(buildTable({ workspaceId: 'other-workspace' })) + + const result = await userTableServerTool.execute( + { operation: 'create_view', args: { tableId: 'tbl_1', name: 'Unsafe View' } }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(result.success).toBe(false) + expect(result.message).toContain('Table not found') + expect(mockCreateTableView).not.toHaveBeenCalled() + }) + + it('rejects unknown columns and select values before creating a View', async () => { + const unknownColumn = await userTableServerTool.execute( + { + operation: 'create_view', + args: { tableId: 'tbl_1', filter: { missing_column: 'x' } }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + const unknownOption = await userTableServerTool.execute( + { + operation: 'create_view', + args: { tableId: 'tbl_1', filter: { Status: 'Missing' } }, + }, + { userId: 'user-1', workspaceId: 'workspace-1' } + ) + + expect(unknownColumn.success).toBe(false) + expect(unknownColumn.message).toContain('Unknown View filter column') + expect(unknownOption.success).toBe(false) + expect(unknownOption.message).toContain('Unknown option') + expect(mockCreateTableView).not.toHaveBeenCalled() + }) +}) + describe('userTableServerTool.delete_rows_by_filter', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 9d3f35438ff..50c5e092804 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import { type MothershipResource, tableViewResourceId } from '@/lib/copilot/resources/types' import { assertServerToolNotAborted, type BaseServerTool, @@ -48,6 +49,7 @@ import { markTableDeleteFailed, runTableDelete } from '@/lib/table/delete-runner import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner' import { markTableJobRunning, releaseJobClaim } from '@/lib/table/jobs/service' import { assertRowDelete, assertRowUpdate, patchColumnIds } from '@/lib/table/mutation-locks' +import { pruneViewFilterForColumns } from '@/lib/table/query-builder/converters' import { batchInsertRows, batchUpdateRows, @@ -79,6 +81,8 @@ import type { WorkflowGroupOutput, } from '@/lib/table/types' import { markTableUpdateFailed, runTableUpdate } from '@/lib/table/update-runner' +import { resolveSelectOptionId } from '@/lib/table/validation' +import { createTableView, listTableViews } from '@/lib/table/views/service' import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' import { addWorkflowGroup, @@ -108,10 +112,59 @@ type UserTableResult = { success: boolean message: string data?: any + resources?: MothershipResource[] } const MAX_BATCH_SIZE = CSV_MAX_BATCH_SIZE +function canonicalViewFilter(filter: Filter, columns: ColumnDefinition[]): Filter { + const normalized: Filter = {} + for (const [field, condition] of Object.entries(filter)) { + if ((field === '$and' || field === '$or') && Array.isArray(condition)) { + normalized[field] = condition.map((group) => canonicalViewFilter(group as Filter, columns)) + continue + } + const column = columns.find((candidate) => columnMatchesRef(candidate, field)) + if (!column) throw new Error(`Unknown View filter column: ${field}`) + if (column.type === 'select') { + const operands: unknown[] = [] + if (condition !== null && typeof condition === 'object' && !Array.isArray(condition)) { + for (const [operator, operand] of Object.entries(condition)) { + if (operator === '$empty') continue + operands.push(...(Array.isArray(operand) ? operand : [operand])) + } + } else { + operands.push(...(Array.isArray(condition) ? condition : [condition])) + } + const unknown = operands.find( + (operand) => + resolveSelectOptionId( + operand as Parameters[0], + column.options ?? [] + ) === null + ) + if (unknown !== undefined) { + throw new Error(`Unknown option "${String(unknown)}" for View column "${column.name}"`) + } + } + normalized[column.id ?? column.name] = condition + } + return resolveFilterSelectValues(normalized, columns) +} + +function canonicalViewSort( + sort: Record, + columns: ColumnDefinition[] +): Record { + const normalized: Record = {} + for (const [field, direction] of Object.entries(sort)) { + const column = columns.find((candidate) => columnMatchesRef(candidate, field)) + if (!column) throw new Error(`Unknown View sort column: ${field}`) + normalized[column.id ?? column.name] = direction + } + return normalized +} + async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspaceId: string) { const record = await resolveWorkspaceFileReference(workspaceId, fileReference) if (!record) { @@ -712,6 +765,71 @@ export const userTableServerTool: BaseServerTool } } + case 'create_view': { + if (!args.tableId) { + return { success: false, message: 'Table ID is required' } + } + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + + const table = await getTableById(args.tableId) + if (!table || table.workspaceId !== workspaceId) { + return { success: false, message: `Table not found: ${args.tableId}` } + } + + const filter = args.filter + ? pruneViewFilterForColumns( + canonicalViewFilter(args.filter as Filter, table.schema.columns), + table.schema.columns + ) + : null + const sort = args.sort + ? canonicalViewSort(args.sort as Record, table.schema.columns) + : null + + let viewName = + typeof args.name === 'string' && args.name.trim() + ? args.name.trim() + : `${table.name} View` + if (!(typeof args.name === 'string' && args.name.trim())) { + const existingNames = new Set( + (await listTableViews(table.id, table.schema.columns)).map((view) => + view.name.toLowerCase() + ) + ) + const baseName = viewName + let sequence = 2 + while (existingNames.has(viewName.toLowerCase())) { + viewName = `${baseName} ${sequence}` + sequence += 1 + } + } + + assertNotAborted() + const view = await createTableView({ + tableId: table.id, + workspaceId, + name: viewName, + config: { filter, sort }, + userId: context.userId, + columns: table.schema.columns, + }) + + return { + success: true, + message: `Created View "${view.name}" from Table "${table.name}"`, + data: { view }, + resources: [ + { + type: 'view', + id: tableViewResourceId(table.id, view.id), + title: view.name, + }, + ], + } + } + case 'update_row': { if (!args.tableId) { return { success: false, message: 'Table ID is required' } diff --git a/apps/sim/lib/copilot/tools/tool-display.ts b/apps/sim/lib/copilot/tools/tool-display.ts index 091d289a5b1..d448d6bab58 100644 --- a/apps/sim/lib/copilot/tools/tool-display.ts +++ b/apps/sim/lib/copilot/tools/tool-display.ts @@ -319,6 +319,8 @@ function userTableTitle(args: ToolArgs): string { return 'Listing enrichments' case 'add_enrichment': return `Adding ${name || 'enrichment'}` + case 'create_view': + return `Creating ${name || 'View'}` default: return 'Managing table' } diff --git a/apps/sim/lib/table/query-builder/__tests__/converters.test.ts b/apps/sim/lib/table/query-builder/__tests__/converters.test.ts index 131fdf7a74a..a944a76bbb2 100644 --- a/apps/sim/lib/table/query-builder/__tests__/converters.test.ts +++ b/apps/sim/lib/table/query-builder/__tests__/converters.test.ts @@ -10,6 +10,7 @@ import { filterRulesToFilter, filterToRules, pruneFilterForColumns, + pruneViewFilterForColumns, } from '@/lib/table/query-builder/converters' import type { ColumnDefinition, FilterRule } from '@/lib/table/types' @@ -210,3 +211,21 @@ describe('pruneFilterForColumns', () => { expect(pruneFilterForColumns(null, [single])).toBeNull() }) }) + +describe('pruneViewFilterForColumns', () => { + it('recursively drops deleted stable column ids and empty groups', () => { + const columns: ColumnDefinition[] = [{ id: 'col_live', name: 'Live', type: 'text' }] + expect( + pruneViewFilterForColumns( + { + $or: [ + { col_gone: { $eq: 'x' } }, + { $and: [{ col_live: { $contains: 'A' } }, { col_gone: { $eq: 'y' } }] }, + ], + }, + columns + ) + ).toEqual({ $or: [{ $and: [{ col_live: { $contains: 'A' } }] }] }) + expect(pruneViewFilterForColumns({ col_gone: 'x' }, columns)).toBeNull() + }) +}) diff --git a/apps/sim/lib/table/query-builder/converters.ts b/apps/sim/lib/table/query-builder/converters.ts index 297a244c3e4..418fbec77aa 100644 --- a/apps/sim/lib/table/query-builder/converters.ts +++ b/apps/sim/lib/table/query-builder/converters.ts @@ -4,7 +4,7 @@ import { generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import { columnMatchesRef } from '@/lib/table/column-keys' +import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys' import { MULTI_SELECT_FILTER_OPERATORS, SINGLE_SELECT_FILTER_OPERATORS, @@ -112,6 +112,40 @@ export function pruneFilterForColumns( return filterRulesToFilter(kept, columns) } +/** + * Drops View filter fields whose stable column ids no longer exist. + * + * Unlike {@link pruneFilterForColumns}, this intentionally removes unresolved + * fields: View filters are persisted with stable ids, so an unresolved field is + * necessarily a deleted column rather than a model-authored display name that + * should be rejected by the server. + */ +export function pruneViewFilterForColumns( + filter: Filter | null, + columns: ColumnDefinition[] +): Filter | null { + if (!filter) return null + const live = new Set(columns.map(getColumnId)) + + const prune = (current: Filter): Filter => { + const next: Filter = {} + for (const [field, condition] of Object.entries(current)) { + if ((field === '$and' || field === '$or') && Array.isArray(condition)) { + const groups = condition + .map((group) => prune(group as Filter)) + .filter((group) => Object.keys(group).length > 0) + if (groups.length > 0) next[field] = groups + continue + } + if (live.has(field)) next[field] = condition + } + return next + } + + const pruned = prune(filter) + return Object.keys(pruned).length > 0 ? pruned : null +} + /** Converts a single UI sort rule to a Sort object for API queries. */ export function sortRuleToSort(rule: SortRule | null): Sort | null { if (!rule || !rule.column) return null diff --git a/apps/sim/lib/table/views/service.test.ts b/apps/sim/lib/table/views/service.test.ts index 5b048954313..26bb45fa906 100644 --- a/apps/sim/lib/table/views/service.test.ts +++ b/apps/sim/lib/table/views/service.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, it } from 'vitest' import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types' -import { pruneViewConfig } from '@/lib/table/views/service' +import { pruneViewConfig, validateTableViewConfig } from '@/lib/table/views/service' const columns: ColumnDefinition[] = [ { id: 'col_a', name: 'Name', type: 'text' }, @@ -32,11 +32,17 @@ describe('pruneViewConfig', () => { expect(pruneViewConfig({ sort: { col_a: 'desc' } }, columns).sort).toEqual({ col_a: 'desc' }) }) - it('leaves the filter untouched even when it references a deleted column', () => { - // Pruning a predicate would silently widen the view's row set — surfacing a - // stale condition the user can see and remove is the safer failure. - const filter = { col_gone: { $eq: 'x' } } - expect(pruneViewConfig({ filter }, columns).filter).toEqual(filter) + it('prunes deleted columns from nested filters without breaking the View', () => { + const filter = { + $or: [ + { col_gone: { $eq: 'x' } }, + { $and: [{ col_a: { $contains: 'A' } }, { col_gone: { $eq: 'y' } }] }, + ], + } + expect(pruneViewConfig({ filter }, columns).filter).toEqual({ + $or: [{ $and: [{ col_a: { $contains: 'A' } }] }], + }) + expect(pruneViewConfig({ filter: { col_gone: 'x' } }, columns).filter).toBeNull() }) it('leaves absent keys absent rather than materializing empty ones', () => { @@ -50,3 +56,20 @@ describe('pruneViewConfig', () => { ]) }) }) + +describe('validateTableViewConfig', () => { + it('accepts stable column ids and rejects display names or unknown ids', () => { + expect(() => + validateTableViewConfig( + { filter: { col_a: { $contains: 'A' } }, sort: { col_b: 'asc' } }, + columns + ) + ).not.toThrow() + expect(() => validateTableViewConfig({ filter: { Name: 'A' } }, columns)).toThrow( + 'Unknown View filter column: Name' + ) + expect(() => validateTableViewConfig({ sort: { col_gone: 'desc' } }, columns)).toThrow( + 'Unknown View sort column: col_gone' + ) + }) +}) diff --git a/apps/sim/lib/table/views/service.ts b/apps/sim/lib/table/views/service.ts index 1b575bb618a..d24b54998c3 100644 --- a/apps/sim/lib/table/views/service.ts +++ b/apps/sim/lib/table/views/service.ts @@ -15,6 +15,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, asc, eq, ne, sql } from 'drizzle-orm' import { getColumnId } from '@/lib/table/column-keys' +import { pruneViewFilterForColumns } from '@/lib/table/query-builder/converters' import type { ColumnDefinition, TableViewConfig } from '@/lib/table/types' const logger = createLogger('TableViewsService') @@ -47,9 +48,9 @@ export class TableViewValidationError extends Error { * every view on every column delete, stale ids are pruned here on read — the * stored blob stays as-is and self-heals on the next save. * - * `filter` is deliberately left untouched: pruning a predicate would silently - * widen the view's row set, which is worse than surfacing a filter the user can - * see and remove. The filter builder already renders a stale column id as-is. + * A deleted filter column is removed recursively. This can widen the result set, + * but leaves the View usable and matches the visible filter controls rather than + * retaining a predicate the user can no longer edit. */ export function pruneViewConfig( config: TableViewConfig, @@ -58,6 +59,10 @@ export function pruneViewConfig( const live = new Set(columns.map(getColumnId)) const pruned: TableViewConfig = { ...config } + if (config.filter) { + pruned.filter = pruneViewFilterForColumns(config.filter, columns) + } + if (config.columnOrder) pruned.columnOrder = config.columnOrder.filter((id) => live.has(id)) if (config.pinnedColumns) pruned.pinnedColumns = config.pinnedColumns.filter((id) => live.has(id)) if (config.hiddenColumns) pruned.hiddenColumns = config.hiddenColumns.filter((id) => live.has(id)) @@ -79,6 +84,35 @@ export function pruneViewConfig( return pruned } +/** Rejects new writes that do not use stable ids from the source Table schema. */ +export function validateTableViewConfig( + config: TableViewConfig, + columns: ColumnDefinition[] +): void { + const live = new Set(columns.map(getColumnId)) + const assertFilter = (filter: NonNullable) => { + for (const [field, condition] of Object.entries(filter)) { + if ((field === '$and' || field === '$or') && Array.isArray(condition)) { + for (const group of condition) { + assertFilter(group as NonNullable) + } + continue + } + if (!live.has(field)) { + throw new TableViewValidationError(`Unknown View filter column: ${field}`) + } + } + } + if (config.filter) assertFilter(config.filter) + if (config.sort) { + for (const field of Object.keys(config.sort)) { + if (!live.has(field)) { + throw new TableViewValidationError(`Unknown View sort column: ${field}`) + } + } + } +} + function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinition[]): TableView { return { id: row.id, @@ -92,7 +126,7 @@ function toTableView(row: typeof tableViews.$inferSelect, columns: ColumnDefinit } } -/** Every view on a table, oldest first, with stale column references pruned. */ +/** Every View on a Table, alphabetically by name, with stale column references pruned. */ export async function listTableViews( tableId: string, columns: ColumnDefinition[] @@ -101,11 +135,27 @@ export async function listTableViews( .select() .from(tableViews) .where(eq(tableViews.tableId, tableId)) - .orderBy(asc(tableViews.createdAt), asc(tableViews.id)) + .orderBy(asc(sql`lower(${tableViews.name})`), asc(tableViews.createdAt), asc(tableViews.id)) return rows.map((row) => toTableView(row, columns)) } +/** Resolves a View by stable id for chat resources and other internal callers. */ +export async function getTableView(viewId: string): Promise { + const [row] = await db.select().from(tableViews).where(eq(tableViews.id, viewId)).limit(1) + if (!row) return null + return { + id: row.id, + tableId: row.tableId, + name: row.name, + config: (row.config ?? {}) as TableViewConfig, + isDefault: row.isDefault, + createdBy: row.createdBy, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } +} + function normalizeName(name: string): string { const trimmed = name.trim() if (!trimmed) throw new TableViewValidationError('View name cannot be empty') @@ -123,6 +173,7 @@ export interface CreateTableViewData { export async function createTableView(data: CreateTableViewData): Promise { const name = normalizeName(data.name) + validateTableViewConfig(data.config, data.columns) const [row] = await db .insert(tableViews) @@ -162,6 +213,8 @@ export interface UpdateTableViewData { * can't each replace the whole blob from their own stale snapshot. */ export async function updateTableView(data: UpdateTableViewData): Promise { + if (data.config !== undefined) validateTableViewConfig(data.config, data.columns) + if (data.configPatch !== undefined) validateTableViewConfig(data.configPatch, data.columns) const patch: Partial = { updatedAt: new Date() } if (data.name !== undefined) patch.name = normalizeName(data.name) if (data.config !== undefined) patch.config = data.config