diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx index 54a2c7f2dea..c628cc9a390 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -1,9 +1,13 @@ 'use client' import type { RowExecutionMetadata } from '@/lib/table' +import { + CellRender, + type ReferenceCellAction, + resolveCellRender, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render' import type { SaveReason } from '../../../types' import type { DisplayColumn } from '../types' -import { CellRender, resolveCellRender } from './cell-render' import { InlineEditor } from './inline-editors' interface CellContentProps { @@ -25,6 +29,8 @@ interface CellContentProps { waitingOnLabels?: string[] /** Column is an enrichment output — a completed-but-empty cell renders "Not found". */ isEnrichmentOutput?: boolean + /** Opens the inline row preview for a populated Reference cell. */ + referenceAction?: ReferenceCellAction } /** @@ -44,6 +50,7 @@ export function CellContent({ onCancel, waitingOnLabels, isEnrichmentOutput, + referenceAction, }: CellContentProps) { const kind = resolveCellRender({ value, @@ -67,7 +74,7 @@ export function CellContent({ /> )} - + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx new file mode 100644 index 00000000000..97d21fe4040 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.tsx @@ -0,0 +1,120 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' + +vi.mock('@sim/emcn', () => ({ + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Checkbox: () => null, + Chip: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), + cn: (...values: Array) => values.filter(Boolean).join(' '), + Tooltip: { + Root: ({ children }: { children: React.ReactNode }) => children, + Trigger: ({ children }: { children: React.ReactNode }) => children, + Content: ({ children }: { children: React.ReactNode }) => children, + }, +})) + +vi.mock('@/app/workspace/[workspaceId]/logs/utils', () => ({ + StatusBadge: () => null, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell', + () => ({ SimResourceCell: () => null }) +) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({ + resolveSelectOptions: () => [], + SelectPill: () => null, +})) + +import { + CellRender, + resolveCellRender, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render' + +const REFERENCE_COLUMN: DisplayColumn = { + id: 'col-account', + key: 'col-account', + name: 'Account', + type: 'reference', + referenceTableId: 'table-accounts', + groupSize: 1, + groupStartColIndex: 0, + headerLabel: 'Account', + isGroupStart: true, +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('reference cell rendering', () => { + it('resolves a stored row ID to a chip labeled with the reference column name', () => { + expect( + resolveCellRender({ + value: 'row-account-1', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + }) + ).toMatchObject({ kind: 'column-chip', label: 'Account' }) + }) + + it('keeps an empty reference cell empty', () => { + expect( + resolveCellRender({ + value: '', + exec: undefined, + column: REFERENCE_COLUMN, + waitingOnLabels: undefined, + }) + ).toEqual({ kind: 'empty' }) + }) + + it('opens the referenced row from the chip without exposing its stored row ID', () => { + const onReferenceClick = vi.fn() + + act(() => { + root.render( + + ) + }) + + const chip = container.querySelector('button') + expect(chip?.textContent).toBe('Account') + + act(() => chip?.click()) + + expect(onReferenceClick).toHaveBeenCalledOnce() + expect(container.textContent).not.toContain('row-account-1') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx index 4e16d03912b..d8f2a8da750 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -2,7 +2,7 @@ import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn' +import { Badge, Checkbox, Chip, cn, Tooltip } from '@sim/emcn' import { parse } from 'tldts' import { faviconUrl } from '@/lib/core/utils/favicon' import type { RowExecutionMetadata, SelectOption } from '@/lib/table' @@ -28,6 +28,7 @@ export type CellRenderKind = // Plain typed cells | { kind: 'boolean'; checked: boolean } | { kind: 'select'; options: SelectOption[] } + | { kind: 'column-chip'; label: string; icon: React.ComponentType<{ className?: string }> } | { kind: 'json'; text: string } | { kind: 'date'; text: string } | { kind: 'url'; text: string; href: string; domain: string } @@ -128,6 +129,17 @@ export function resolveCellRender({ if (column.type === 'select') { return { kind: 'select', options: resolveSelectOptions(column, value) } } + const typeDefinition = columnTypeOf(column) + if (typeDefinition.referencePreview) { + const rowId = typeDefinition.referencePreview.getRowId(value) + return rowId + ? { + kind: 'column-chip', + label: typeDefinition.referencePreview.getChipLabel(column), + icon: typeDefinition.icon, + } + : { kind: 'empty' } + } if (isNull) return { kind: 'empty' } // Formatted here rather than in a render branch because the symbol and // fraction digits come from the COLUMN's currency, which the render switch @@ -251,9 +263,19 @@ function extractSimResourceInfo( interface CellRenderProps { kind: CellRenderKind isEditing: boolean + referenceAction?: ReferenceCellAction +} + +export interface ReferenceCellAction { + expanded: boolean + onClick: () => void } -export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { +export function CellRender({ + kind, + isEditing, + referenceAction, +}: CellRenderProps): React.ReactElement | null { const valueText = kind.kind === 'value' ? kind.text : null const revealedValueText = useTypewriter(valueText) @@ -375,6 +397,25 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle ) + case 'column-chip': { + const ChipIcon = kind.icon + return ( + { + event.stopPropagation() + referenceAction?.onClick() + }} + > + {kind.label} + + ) + } + case 'json': return ( + expandedReference: ReferencePreviewTarget | null + onReferenceClick: (target: ReferencePreviewTarget) => void } function cellRangeRowChanged( @@ -138,7 +148,9 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.activeDispatches !== next.activeDispatches || prev.pinnedOffsets !== next.pinnedOffsets || prev.lastPinnedColKey !== next.lastPinnedColKey || - prev.findMatchColumns !== next.findMatchColumns + prev.findMatchColumns !== next.findMatchColumns || + prev.expandedReference !== next.expandedReference || + prev.onReferenceClick !== next.onReferenceClick ) { return false } @@ -188,6 +200,8 @@ export const DataRow = React.memo(function DataRow({ pinnedOffsets, lastPinnedColKey, findMatchColumns, + expandedReference, + onReferenceClick, }: DataRowProps) { const sel = normalizedSelection /** @@ -301,6 +315,22 @@ export const DataRow = React.memo(function DataRow({ {columns.map((column, colIndex) => { + const value = + pendingCellValue && column.key in pendingCellValue + ? pendingCellValue[column.key] + : row.data[column.key] + const referencePreview = columnTypeOf(column).referencePreview + const referenceRowId = referencePreview?.getRowId(value) ?? null + const referenceTableId = referencePreview?.getTableId(column) + const referenceTarget = + referenceTableId && referenceRowId + ? { + sourceRowId: row.id, + sourceColumnKey: column.key, + referenceTableId, + referenceRowId, + } + : null const inRange = sel !== null && rowIndex >= sel.startRow && @@ -396,11 +426,7 @@ export const DataRow = React.memo(function DataRow({
onReferenceClick(referenceTarget), + } + : undefined + } />
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx new file mode 100644 index 00000000000..f4b89af010d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.test.tsx @@ -0,0 +1,167 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createTableColumn, createTableDefinition, createTableRow } from '@sim/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { tableQuery, rowQuery } = vi.hoisted(() => ({ + tableQuery: { + data: undefined as ReturnType | undefined, + isLoading: false, + isError: false, + }, + rowQuery: { + data: undefined as ReturnType | null | undefined, + isLoading: false, + isError: false, + }, +})) + +vi.mock('@/hooks/queries/tables', () => ({ + useTable: () => tableQuery, + useTableRow: () => rowQuery, +})) + +vi.mock('@/lib/table/column-types', () => ({ + columnTypeById: () => ({ icon: () => null }), +})) + +vi.mock('@sim/emcn/icons', () => ({ + Loader: () => null, +})) + +vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells', () => ({ + CellContent: ({ value }: { value: unknown }) => {String(value)}, +})) + +vi.mock( + '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon', + () => ({ ColumnTypeIcon: () => null }) +) + +import { + REFERENCE_ROW_PREVIEW_HEIGHT, + ReferenceRowPreview, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const columns = [ + createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }), + createTableColumn({ id: 'col-tier', name: 'Tier', type: 'string' }), + ] + tableQuery.data = createTableDefinition({ + id: 'table-accounts', + name: 'Accounts', + columns, + }) + tableQuery.isLoading = false + tableQuery.isError = false + rowQuery.data = createTableRow({ + id: 'row-account-1', + data: { 'col-name': 'Acme', 'col-tier': 'Enterprise' }, + }) + rowQuery.isLoading = false + rowQuery.isError = false + container = document.createElement('div') + document.body.appendChild(container) + act(() => { + root = createRoot(container) + }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +function renderPreview() { + act(() => { + root.render( + + + + +
+ ) + }) +} + +describe('ReferenceRowPreview', () => { + it('shows the referenced table schema and the matching row inline', () => { + renderPreview() + + expect(container.textContent).toContain('Accounts') + expect(container.textContent).toContain('Name') + expect(container.textContent).toContain('Tier') + expect(container.textContent).toContain('Acme') + expect(container.textContent).toContain('Enterprise') + expect(container.textContent).not.toContain('Open in sub view') + const goToTableLink = Array.from(container.querySelectorAll('a')).find( + (link) => link.textContent === 'Go to table' + ) + expect(goToTableLink?.getAttribute('href')).toBe('/workspace/workspace-1/tables/table-accounts') + expect(goToTableLink?.parentElement?.className).toContain('h-9') + const previewCell = container.querySelector('tbody > tr > td') + expect(previewCell?.className).toContain('overflow-clip') + expect(previewCell?.className).toContain('border-r') + expect(container.querySelector('td > div')?.className).toContain('sticky left-0') + expect(container.querySelector('td > div')?.className).toContain('w-0') + expect(container.querySelector('td > div')?.className).toContain( + `h-[${REFERENCE_ROW_PREVIEW_HEIGHT}px]` + ) + const subtable = container.querySelector('td table') + expect(subtable?.className).toContain('w-[100cqw]') + expect(subtable?.className).toContain('border-t') + expect(subtable?.className).toContain('border-b') + expect(subtable?.querySelectorAll('col')).toHaveLength(3) + expect(container.querySelector('.overscroll-x-contain')?.className).toContain( + 'overscroll-x-contain' + ) + expect(container.innerHTML).not.toContain('rounded-md') + }) + + it('shows no match when the stored row ID does not resolve', () => { + rowQuery.data = null + + renderPreview() + + expect(container.textContent).toContain('No matching row') + }) + + it('shows a loading state while either referenced resource is loading', () => { + rowQuery.isLoading = true + + renderPreview() + + expect(container.textContent).toContain('Loading referenced row') + }) + + it('keeps non-404 failures distinct from missing rows', () => { + rowQuery.isError = true + + renderPreview() + + expect(container.textContent).toContain("Couldn't load referenced row") + expect(container.textContent).not.toContain('No matching row') + }) + + it('shows an empty-schema state when the referenced table has no columns', () => { + if (!tableQuery.data) throw new Error('Expected the table fixture to be initialized') + tableQuery.data.schema.columns = [] + + renderPreview() + + expect(container.textContent).toContain('This table has no columns') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx new file mode 100644 index 00000000000..2ad4e6c9a78 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview.tsx @@ -0,0 +1,153 @@ +'use client' + +import { memo, type ReactNode, useMemo } from 'react' +import { buttonVariants } from '@sim/emcn' +import { Loader } from '@sim/emcn/icons' +import { noop } from '@sim/utils/helpers' +import Link from 'next/link' +import { columnTypeById } from '@/lib/table/column-types' +import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells' +import { ColumnTypeIcon } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon' +import { expandToDisplayColumns } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils' +import { useTable, useTableRow } from '@/hooks/queries/tables' + +/** + * Must match the sticky anchor's `h-[144px]` class below because the row + * virtualizer reserves this exact height. The zero-width anchor stays sticky + * across the full table width, while its `100cqw` child uses TableGrid's + * inline-size query container to cover the visible viewport. + */ +export const REFERENCE_ROW_PREVIEW_HEIGHT = 144 + +const ReferenceIcon = columnTypeById('reference').icon + +interface ReferenceRowPreviewProps { + workspaceId: string + referenceTableId: string + referenceRowId: string + colSpan: number +} + +export const ReferenceRowPreview = memo(function ReferenceRowPreview({ + workspaceId, + referenceTableId, + referenceRowId, + colSpan, +}: ReferenceRowPreviewProps) { + const tableQuery = useTable(workspaceId, referenceTableId) + const rowQuery = useTableRow(workspaceId, referenceTableId, referenceRowId) + const table = tableQuery.data + const row = rowQuery.data + const columns = useMemo( + () => expandToDisplayColumns(table?.schema.columns ?? [], []), + [table?.schema.columns] + ) + + let content: ReactNode + if (tableQuery.isLoading || rowQuery.isLoading) { + content = ( +
+ + Loading referenced row +
+ ) + } else if (tableQuery.isError || rowQuery.isError) { + content = ( +
+ Couldn't load referenced row +
+ ) + } else if (!row) { + content = ( +
+ No matching row +
+ ) + } else if (columns.length === 0) { + content = ( +
+ This table has no columns +
+ ) + } else { + content = ( + + + {columns.map((column) => ( + + ))} + + + + + {columns.map((column) => ( + + ))} + + + + + {columns.map((column) => ( + + ))} + + +
+ + + {column.name} + + +
+
+ +
+
+
+ ) + } + + return ( + + +
+
+
+ + {table?.name ?? 'Referenced table'} +
+ +
+ {content} +
+ +
+ + Go to table + +
+
+
+ + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 85f8d0cddfc..ab56b4e2b15 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { cn, toast, useToast, writeTextToClipboard } from '@sim/emcn' import { Loader, TableX } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -31,6 +31,10 @@ import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { FindBar } from '@/app/workspace/[workspaceId]/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { + REFERENCE_ROW_PREVIEW_HEIGHT, + ReferenceRowPreview, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/reference-row-preview' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' import { useTimezone } from '@/hooks/queries/general-settings' @@ -66,7 +70,7 @@ import { DataRow } from './data-row' import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' import { RemoteSelectionOverlay } from './remote-selection-overlay' import { AddRowButton, SelectAllCheckbox, TableColGroup } from './table-primitives' -import type { DisplayColumn } from './types' +import type { DisplayColumn, ReferencePreviewTarget } from './types' import { buildHeaderGroups, buildTableSelectionContext, @@ -83,6 +87,7 @@ import { expandToDisplayColumns, horizontalEdgeScrollVelocity, isCellInSelection, + isSameReferencePreviewTarget, moveCell, ROW_SELECTION_ALL, ROW_SELECTION_NONE, @@ -648,19 +653,7 @@ export function TableGrid({ */ const [headerHeight, setHeaderHeight] = useState(0) const [rowHeight, setRowHeight] = useState(ROW_HEIGHT_ESTIMATE) - - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => rowHeight, - overscan: 12, - scrollMargin: headerHeight, - getItemKey: (index) => rows[index]?.id ?? index, - }) - - useEffect(() => { - rowVirtualizer.measure() - }, [rowHeight, rowVirtualizer]) + const [expandedReference, setExpandedReference] = useState(null) useLayoutEffect(() => { const el = theadRef.current @@ -894,6 +887,36 @@ export function TableGrid({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, hiddenColumns, tableWorkflowGroups]) + const activeExpandedReference = useMemo(() => { + if (!expandedReference) return null + const sourceRow = rows.find((row) => row.id === expandedReference.sourceRowId) + const sourceColumn = displayColumns.find( + (column) => column.key === expandedReference.sourceColumnKey + ) + const referencePreview = sourceColumn ? columnTypeOf(sourceColumn).referencePreview : undefined + return sourceRow?.data[expandedReference.sourceColumnKey] === + expandedReference.referenceRowId && + sourceColumn && + referencePreview?.getTableId(sourceColumn) === expandedReference.referenceTableId + ? expandedReference + : null + }, [displayColumns, rows, expandedReference]) + const expandedSourceRowId = activeExpandedReference?.sourceRowId ?? null + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + rowHeight + (rows[index]?.id === expandedSourceRowId ? REFERENCE_ROW_PREVIEW_HEIGHT : 0), + overscan: 12, + scrollMargin: headerHeight, + getItemKey: (index) => rows[index]?.id ?? index, + }) + + useEffect(() => { + rowVirtualizer.measure() + }, [rowHeight, expandedSourceRowId, rowVirtualizer]) + /** Column id → its rendered index (matches the cells' `data-col`), for placing overlays. * Only built when collaborators are present (the overlay it feeds is gated on that too), * so solo editing never pays the map build. */ @@ -2774,6 +2797,14 @@ export function TableGrid({ [] ) + const handleReferenceClick = useCallback((target: ReferencePreviewTarget) => { + setEditingCell(null) + setInitialCharacter(null) + setExpandedReference((current) => + isSameReferencePreviewTarget(current, target) ? null : target + ) + }, []) + const handleCellDoubleClick = useCallback( (rowId: string, columnName: string, columnKey: string) => { const column = columnsRef.current.find((c) => c.key === columnKey) @@ -2890,8 +2921,9 @@ export function TableGrid({ if (!el) return const handleKeyDown = (e: KeyboardEvent) => { - const tag = (e.target as HTMLElement).tagName - if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return + const target = e.target + if (!(target instanceof HTMLElement)) return + if (target.closest('input, textarea, select, button, a, [contenteditable]')) return if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'y')) { e.preventDefault() @@ -4681,7 +4713,7 @@ export function TableGrid({ ref={scrollRef} tabIndex={-1} className={cn( - 'min-h-0 flex-1 overflow-auto overscroll-none outline-none', + 'min-h-0 flex-1 overflow-auto overscroll-none outline-none [container-type:inline-size]', resizingColumn && 'select-none' )} data-table-scroll @@ -4946,48 +4978,64 @@ export function TableGrid({ const index = virtualRow.index const row = rows[index] if (!row) return null + const rowReference = + activeExpandedReference?.sourceRowId === row.id + ? activeExpandedReference + : null return ( - 0 ? pinnedOffsets : undefined} - lastPinnedColKey={lastPinnedColKey} - findMatchColumns={findMatchColumnsByRowId.get(row.id)} - /> + + 0 ? pinnedOffsets : undefined} + lastPinnedColKey={lastPinnedColKey} + findMatchColumns={findMatchColumnsByRowId.get(row.id)} + expandedReference={rowReference} + onReferenceClick={handleReferenceClick} + /> + {rowReference ? ( + + ) : null} + ) })} {paddingBottom > 0 && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts index af5cceea88c..0fd4875184a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts @@ -35,3 +35,10 @@ export interface DisplayColumn extends ColumnDefinition { /** True when this is the leftmost sibling of its group (or non-grouped). */ isGroupStart: boolean } + +export interface ReferencePreviewTarget { + sourceRowId: string + sourceColumnKey: string + referenceTableId: string + referenceRowId: string +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts index 77a68db51c2..93954776f02 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.test.ts @@ -15,6 +15,7 @@ import { columnNameIssue, drainTargetForChip, horizontalEdgeScrollVelocity, + isSameReferencePreviewTarget, selectedColumnIds, } from './utils' @@ -27,6 +28,23 @@ function columns(count: number): DisplayColumn[] { const rowIds = (count: number) => Array.from({ length: count }, (_, i) => `r${i}`) +describe('isSameReferencePreviewTarget', () => { + const target = { + sourceRowId: 'source-row', + sourceColumnKey: 'account-column', + referenceTableId: 'accounts-table', + referenceRowId: 'account-row', + } + + it('matches only the same source cell and referenced row', () => { + expect(isSameReferencePreviewTarget(target, target)).toBe(true) + expect(isSameReferencePreviewTarget(null, target)).toBe(false) + for (const key of Object.keys(target) as Array) { + expect(isSameReferencePreviewTarget({ ...target, [key]: 'different' }, target)).toBe(false) + } + }) +}) + describe('horizontalEdgeScrollVelocity', () => { const getVelocity = (pointerX: number) => horizontalEdgeScrollVelocity({ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index a0bb079943a..a04b28fc0d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -14,9 +14,12 @@ import type { import { getColumnId } from '@/lib/table/column-keys' import { NAME_PATTERN, TABLE_LIMITS } from '@/lib/table/constants' import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps' +import type { + DisplayColumn, + ReferencePreviewTarget, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types' import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' -import type { DisplayColumn } from './types' /** * `all` means "every row matching the active filter" — including rows not yet loaded by the @@ -31,6 +34,18 @@ export type RowSelection = export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' } export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' } +export function isSameReferencePreviewTarget( + left: ReferencePreviewTarget | null, + right: ReferencePreviewTarget +): boolean { + return ( + left?.sourceRowId === right.sourceRowId && + left.sourceColumnKey === right.sourceColumnKey && + left.referenceTableId === right.referenceTableId && + left.referenceRowId === right.referenceRowId + ) +} + interface HorizontalEdgeScrollVelocityInput { pointerX: number visibleLeft: number diff --git a/apps/sim/hooks/queries/tables.test.ts b/apps/sim/hooks/queries/tables.test.ts index 0f81a28108f..200ae0e20d4 100644 --- a/apps/sim/hooks/queries/tables.test.ts +++ b/apps/sim/hooks/queries/tables.test.ts @@ -1,6 +1,8 @@ /** * @vitest-environment node */ + +import { useQuery } from '@tanstack/react-query' import { beforeEach, describe, expect, it, vi } from 'vitest' const { queryClient, cacheStore } = vi.hoisted(() => { @@ -57,12 +59,16 @@ vi.mock('@sim/emcn', () => ({ toast: { error: vi.fn(), success: vi.fn() }, })) -import type { TableViewWire } from '@/lib/api/contracts/tables' +import { isApiClientError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import { getTableRowContract, type TableViewWire } from '@/lib/api/contracts/tables' import { tableRowsInfiniteOptions, tableRowsParamsKey, + useBatchUpdateTableRows, useDeleteColumn, useRestoreTable, + useTableRow, useUpdateColumn, useUpdateTableView, } from '@/hooks/queries/tables' @@ -91,6 +97,84 @@ beforeEach(() => { vi.clearAllMocks() }) +describe('useTableRow', () => { + function getQueryOptions() { + return vi.mocked(useQuery).mock.calls.at(-1)?.[0] as { + enabled: boolean + queryFn: (context: { signal: AbortSignal }) => Promise + } + } + + it('treats a missing referenced row as zero rows', async () => { + vi.mocked(requestJson).mockRejectedValueOnce({ status: 404 }) + vi.mocked(isApiClientError).mockReturnValueOnce(true) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'missing-row') + + const options = getQueryOptions() + expect(options.enabled).toBe(true) + await expect(options.queryFn({ signal: new AbortController().signal })).resolves.toBeNull() + }) + + it('forwards the row scope and cancellation signal through the shared contract', async () => { + const row = { id: 'row-1', data: { name: 'Acme' } } + const signal = new AbortController().signal + vi.mocked(requestJson).mockResolvedValueOnce({ data: { row } }) + + useTableRow(WORKSPACE_ID, TABLE_ID, row.id) + + await expect(getQueryOptions().queryFn({ signal })).resolves.toEqual(row) + expect(requestJson).toHaveBeenCalledWith(getTableRowContract, { + params: { tableId: TABLE_ID, rowId: row.id }, + query: { workspaceId: WORKSPACE_ID }, + signal, + }) + }) + + it('preserves non-404 API failures', async () => { + const error = { status: 500 } + vi.mocked(requestJson).mockRejectedValueOnce(error) + vi.mocked(isApiClientError).mockReturnValueOnce(true) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'row-1') + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) + + it('preserves failures that are not API client errors', async () => { + const error = new Error('connection failed') + vi.mocked(requestJson).mockRejectedValueOnce(error) + + useTableRow(WORKSPACE_ID, TABLE_ID, 'row-1') + + await expect(getQueryOptions().queryFn({ signal: new AbortController().signal })).rejects.toBe( + error + ) + }) +}) + +describe('useBatchUpdateTableRows', () => { + it('invalidates cached row details after a batch write settles', () => { + const hook = useBatchUpdateTableRows({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) + const updates = [ + { rowId: 'row-1', data: { name: 'Acme' } }, + { rowId: 'row-2', data: { name: 'Globex' } }, + ] + + hook.onSettled?.(undefined, null, { updates }, undefined) + + expect(queryClient.invalidateQueries).toHaveBeenCalledOnce() + const options = queryClient.invalidateQueries.mock.calls[0]?.[0] + expect(options?.queryKey).toEqual(tableKeys.rowsRoot(TABLE_ID)) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-1') })).toBe(true) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-2') })).toBe(true) + expect(options?.predicate({ queryKey: tableKeys.row(TABLE_ID, 'row-3') })).toBe(false) + expect(options?.predicate({ queryKey: tableKeys.infiniteRowsRoot(TABLE_ID) })).toBe(false) + }) +}) + describe('useUpdateTableView autosave ordering', () => { it('serializes config and layout patches for the same table', () => { const hook = useUpdateTableView({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID }) diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 22b4359b9f8..3fc9aae6935 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -59,8 +59,10 @@ import { deleteTableViewContract, deleteWorkflowGroupContract, findTableRowsContract, + type GetTableRowResponse, getEnrichmentDetailContract, getTableContract, + getTableRowContract, type InsertTableRowBodyInput, listActiveDispatchesContract, listTableJobsContract, @@ -217,6 +219,25 @@ async function fetchTableRows({ return { rows, totalCount, nextCursor } } +async function fetchTableRow( + workspaceId: string, + tableId: string, + rowId: string, + signal?: AbortSignal +): Promise { + try { + const response = await requestJson(getTableRowContract, { + params: { tableId, rowId }, + query: { workspaceId }, + signal, + }) + return response.data.row + } catch (error) { + if (isApiClientError(error) && error.status === 404) return null + throw error + } +} + function invalidateRowCount(queryClient: ReturnType, tableId: string) { queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) }) @@ -314,6 +335,21 @@ export function useTable(workspaceId: string | undefined, tableId: string | unde }) } +/** Reads one row on demand. A missing row resolves to null for reference previews. */ +export function useTableRow( + workspaceId: string | undefined, + tableId: string | undefined, + rowId: string | undefined +) { + return useQuery({ + queryKey: tableKeys.row(tableId ?? '', rowId ?? ''), + queryFn: ({ signal }) => + fetchTableRow(workspaceId as string, tableId as string, rowId as string, signal), + enabled: Boolean(workspaceId && tableId && rowId), + staleTime: TABLE_ROWS_STALE_TIME, + }) +} + /** * Shared table-detail query options so non-component callers (e.g. selector * providers) can `ensureQueryData` the same cache entry `useTable` populates. @@ -1228,6 +1264,21 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, + onSettled: (_data, _error, { updates }) => { + const rowIds = new Set(updates.map(({ rowId }) => rowId)) + const rowsRoot = tableKeys.rowsRoot(tableId) + queryClient.invalidateQueries({ + queryKey: rowsRoot, + predicate: (query) => { + const rowId = query.queryKey[rowsRoot.length + 1] + return ( + query.queryKey[rowsRoot.length] === 'row' && + typeof rowId === 'string' && + rowIds.has(rowId) + ) + }, + }) + }, }) } diff --git a/apps/sim/hooks/queries/utils/table-keys.ts b/apps/sim/hooks/queries/utils/table-keys.ts index 5ccf7f34457..a7697e3ff9d 100644 --- a/apps/sim/hooks/queries/utils/table-keys.ts +++ b/apps/sim/hooks/queries/utils/table-keys.ts @@ -25,6 +25,7 @@ export const tableKeys = { exportJobs: (workspaceId?: string) => [...tableKeys.all, 'export-jobs', workspaceId ?? ''] as const, rowsRoot: (tableId: string) => [...tableKeys.detail(tableId), 'rows'] as const, + row: (tableId: string, rowId: string) => [...tableKeys.rowsRoot(tableId), 'row', rowId] as const, /** * Prefix covering only the paged row lists. `rowsRoot` is a shared parent — `find` * hangs off it holding a different shape — so anything walking the cache for row diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 7b1301f0701..1b86febbea4 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -1521,6 +1521,8 @@ export const getTableRowContract = defineRouteContract({ }, }) +export type GetTableRowResponse = ContractJsonResponse + export const updateTableRowContract = defineRouteContract({ method: 'PATCH', path: '/api/table/[tableId]/rows/[rowId]', diff --git a/apps/sim/lib/table/column-types/reference.ts b/apps/sim/lib/table/column-types/reference.ts index 7138c7fe866..5d8c6523bd6 100644 --- a/apps/sim/lib/table/column-types/reference.ts +++ b/apps/sim/lib/table/column-types/reference.ts @@ -15,6 +15,17 @@ export const referenceColumnType: ColumnTypeDefinition = { workflowInputType: 'string', editor: 'text', expandable: false, + referencePreview: { + getChipLabel(column) { + return column.name + }, + getTableId(column) { + return column.referenceTableId + }, + getRowId(value) { + return typeof value === 'string' && value.length > 0 ? value : null + }, + }, coerce: stringColumnType.coerce, diff --git a/apps/sim/lib/table/column-types/types.ts b/apps/sim/lib/table/column-types/types.ts index 31980ec45fc..f9cb14b7a82 100644 --- a/apps/sim/lib/table/column-types/types.ts +++ b/apps/sim/lib/table/column-types/types.ts @@ -73,6 +73,13 @@ export type TypeSpecificColumnKey = (typeof TYPE_SPECIFIC_COLUMN_KEYS)[number] /** Result of coercing a raw value toward a column's declared type. */ export type CoerceResult = { ok: true; value: JsonValue } | { ok: false } +/** Client-side behavior for a column whose stored value can open a referenced row preview. */ +export interface ColumnReferencePreviewDefinition { + getChipLabel(column: ColumnDefinition): string + getTableId(column: ColumnDefinition): string | undefined + getRowId(value: unknown): string | null +} + export interface ColumnTypeDefinition { readonly id: ColumnType @@ -137,6 +144,8 @@ export interface ColumnTypeDefinition { * bounded, structured value. */ readonly expandable: boolean + /** Optional inline referenced-row presentation owned by this column type. */ + readonly referencePreview?: ColumnReferencePreviewDefinition /** `inputMode` for the text editor, when the type wants a specific keypad. */ readonly inputMode?: 'decimal' /**