| 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) => (
+ |
+
+
+ {column.name}
+
+ |
+ ))}
+ |
+
+
+
+
+ {columns.map((column) => (
+ |
+
+
+
+ |
+ ))}
+ |
+
+
+
+ )
+ }
+
+ 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'
/**