Skip to content

Commit 4e66399

Browse files
committed
feat(tables): give the table view source/grants/host and delete the module fork
`TableView({ source, grants, host })` joins `FileView` and `InterfaceView` as a canonical resource view, registered in `CANONICAL_UNITS` so R1a/R2/R4/R6 now police it. `table` moves from "no canonical view yet" to a real one. The interfaces table module stops re-implementing a table. It had grown its own 451-line renderer — cell resolver, favicon-link builder, offset pagination — and that fork is now three files lighter: mint a table source from the surrounding interface source, mount the view. 163 lines become 74. What the module gains by not being a fork: every cell kind the grid draws. Booleans, dates, JSON, links, pinned columns, select pills, and workflow-output state now render identically in a module and in the grid, because they are the same component rather than an approximation of it. The share arm is addressed by `(token, grantId)` and carries no table id at all — the server derives it from the stored layout per request. `workspaceId` is threaded from the source and is `undefined` there, which is what keeps the `sim-resource` chip (and the four workspace-authenticated queries its renderer mounts) off a public page. `cells/cell-render.test.ts` is what holds that. Two things this deliberately does NOT do: - It does not touch the tables page or the mothership panel. Both still mount the full editing shell. Making the panel read-only is a product decision about whether you can edit a table from chat, not a refactor, and it does not belong in a change whose value is deleting a fork. - It does not unify the row page size. The page drains 1000 for client-side sort and filter; this view takes 100. `pageSize` is in the query key, so they do not share row cache — which the code now says plainly instead of claiming otherwise. Fixing it changes behaviour on one surface or the other. Caught while doing it: `CellRender` returns `null` for an empty cell because the grid's virtualizer owns row height. A panel has nothing holding the row open, so an all-empty row collapsed. The floor lives on a wrapper in this view rather than in `CellRender`, so the grid is untouched. A pre-existing test caught it.
1 parent 10d661a commit 4e66399

9 files changed

Lines changed: 312 additions & 333 deletions

File tree

apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/components/table-cell-value/index.ts

Lines changed: 0 additions & 2 deletions
This file was deleted.

apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/components/table-cell-value/table-cell-value.tsx

Lines changed: 0 additions & 157 deletions
This file was deleted.
Lines changed: 34 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
'use client'
22

3-
import { Skeleton, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@sim/emcn'
3+
import { useMemo } from 'react'
44
import { Table as TableIcon } from '@sim/emcn/icons'
55
import { ModuleResourcePicker } from '@/components/resources/interface-view/components/module-renderer/components/module-resource-picker'
6-
import { TableCellValue } from '@/components/resources/interface-view/components/module-renderer/components/table-module/components/table-cell-value'
7-
import { useModuleTable } from '@/components/resources/interface-view/components/module-renderer/components/table-module/hooks/use-module-table'
6+
import { interfaceModuleSeed } from '@/components/resources/interface-view/interface-scope'
87
import { ResourceEmptyState } from '@/components/resources/resource-empty-state'
98
import { useResourceOfKind } from '@/components/resources/resource-provider'
9+
import { TableView } from '@/components/resources/table-view'
1010
import type { InterfaceModule } from '@/lib/interfaces/types'
11-
import { getColumnId } from '@/lib/table/column-keys'
12-
13-
/** Distance from the bottom of the scroller at which the next page is requested. */
14-
const TABLE_MODULE_PREFETCH_PX = 200
15-
16-
/** Stable keys for the loading placeholder rows. */
17-
const LOADING_ROW_KEYS = ['row-1', 'row-2', 'row-3', 'row-4', 'row-5'] as const
11+
import { grantsForShare, shareSource, workspaceSource } from '@/resources'
1812

1913
export interface TableModuleProps {
2014
module: Extract<InterfaceModule, { type: 'table' }>
@@ -28,47 +22,43 @@ export interface TableModuleProps {
2822
}
2923

3024
/**
31-
* Read-only view of a workspace table.
25+
* A workspace table inside an interface.
3226
*
33-
* Deliberately not the table editor — no sorting, filtering, or cell editing —
34-
* but the rows are the real ones: {@link useModuleTable} resolves them from the
35-
* surrounding resource and pages in more as the visitor scrolls, so a
36-
* large table is browsable rather than truncated at the first page. The same
37-
* component serves the editor and a public share; only the source differs.
27+
* This module owns exactly two things: binding itself to a table, and turning
28+
* the surrounding interface source into a *table* source. The table itself is
29+
* the canonical {@link TableView}, so a module renders every cell kind the
30+
* tables grid does — booleans, dates, JSON, links, currency, select pills —
31+
* rather than the approximation it used to carry.
3832
*
39-
* An unbound module authors itself: given `onConfigChange` it renders the same
40-
* chooser column the empty cell offered a moment earlier, so picking the table
41-
* happens where the module is rather than in the inspector.
33+
* The share arm addresses the table by `(token, moduleId)` and carries no table
34+
* id at all: the server derives it from the stored layout on every request, so
35+
* there is nothing here for a visitor to forge.
4236
*/
4337
export function TableModule({ module, onConfigChange }: TableModuleProps) {
4438
const { source } = useResourceOfKind('interface')
4539
const { tableId } = module.config
46-
const {
47-
columns,
48-
rows,
49-
totalCount,
50-
isPending,
51-
isMissing,
52-
isError,
53-
hasNextPage,
54-
isFetchingNextPage,
55-
fetchNextPage,
56-
} = useModuleTable(module)
5740

58-
/**
59-
* Pages in the next batch as the scroller nears its end. Guarded on
60-
* `hasNextPage` so the final page never fires a request, and on
61-
* `isFetchingNextPage` so a fast scroll cannot queue duplicates.
62-
*/
63-
function handleScroll(event: React.UIEvent<HTMLDivElement>): void {
64-
if (!hasNextPage || isFetchingNextPage) return
65-
const { scrollHeight, scrollTop, clientHeight } = event.currentTarget
66-
if (scrollHeight - scrollTop - clientHeight > TABLE_MODULE_PREFETCH_PX) return
67-
fetchNextPage()
68-
}
41+
const seed = interfaceModuleSeed(source, module.id)
42+
const sharedTable = seed?.kind === 'table' ? seed.seed : null
6943

70-
if (source.via === 'workspace' && !tableId) {
71-
if (onConfigChange) {
44+
const tableSource = useMemo(() => {
45+
if (source.via === 'workspace') {
46+
return tableId
47+
? workspaceSource({ kind: 'table', workspaceId: source.workspaceId, resourceId: tableId })
48+
: null
49+
}
50+
return sharedTable
51+
? shareSource({ kind: 'table', token: source.token, grantId: module.id, seed: sharedTable })
52+
: null
53+
}, [source, tableId, sharedTable, module.id])
54+
55+
if (!tableSource) {
56+
/**
57+
* Unbound in the editor: the module authors itself, rendering the same
58+
* chooser the empty cell offered a moment earlier so picking the table
59+
* happens where the module is rather than in the inspector.
60+
*/
61+
if (source.via === 'workspace' && onConfigChange) {
7262
return (
7363
<ModuleResourcePicker
7464
kind='table'
@@ -80,84 +70,5 @@ export function TableModule({ module, onConfigChange }: TableModuleProps) {
8070
return <ResourceEmptyState icon={TableIcon} description='This table is not available.' />
8171
}
8272

83-
if (isError) {
84-
/**
85-
* A visitor is not told where the table lived — "in the workspace" is
86-
* internal state on a public page, and naming it would leak that the share
87-
* belongs to one.
88-
*/
89-
const missingMessage =
90-
source.via === 'workspace'
91-
? 'This table is no longer in the workspace.'
92-
: 'This table is no longer available.'
93-
return (
94-
<ResourceEmptyState
95-
icon={TableIcon}
96-
description={isMissing ? missingMessage : 'This table could not be loaded.'}
97-
/>
98-
)
99-
}
100-
101-
if (isPending) {
102-
return (
103-
<div className='flex h-full flex-col gap-2 p-3'>
104-
{LOADING_ROW_KEYS.map((key) => (
105-
<Skeleton key={key} className='h-[20px] w-full' />
106-
))}
107-
</div>
108-
)
109-
}
110-
111-
if (!columns || columns.length === 0) {
112-
return <ResourceEmptyState icon={TableIcon} description='This table has no columns yet.' />
113-
}
114-
115-
if (rows.length === 0) {
116-
return <ResourceEmptyState icon={TableIcon} description='This table has no rows yet.' />
117-
}
118-
119-
const remaining = totalCount !== null && totalCount > rows.length
120-
121-
return (
122-
<div className='flex h-full min-h-0 flex-col'>
123-
<div onScroll={handleScroll} className='min-h-0 flex-1 overflow-auto overscroll-contain'>
124-
<Table>
125-
<TableHeader>
126-
<TableRow>
127-
{columns.map((column) => (
128-
<TableHead key={getColumnId(column)} className='whitespace-nowrap'>
129-
{column.name}
130-
</TableHead>
131-
))}
132-
</TableRow>
133-
</TableHeader>
134-
{/**
135-
* EMCN's `TableBody` drops the last row's rule (`[&_tr:last-child]:border-0`)
136-
* so a table can sit flush against a page. Inside a module the rows
137-
* end mid-pane instead, and without the closing line the list looks
138-
* truncated — restored here. `!` because that rule targets the same
139-
* `tr:last-child` and would otherwise out-specify a row-level class.
140-
*/}
141-
<TableBody className='[&_tr:last-child]:!border-b'>
142-
{rows.map((row) => (
143-
<TableRow key={row.id}>
144-
{columns.map((column) => (
145-
<TableCell key={getColumnId(column)} className='max-w-[240px]'>
146-
<TableCellValue value={row.data[getColumnId(column)]} column={column} />
147-
</TableCell>
148-
))}
149-
</TableRow>
150-
))}
151-
</TableBody>
152-
</Table>
153-
</div>
154-
{remaining ? (
155-
<p className='border-[var(--border)] border-t px-3 py-2 text-[var(--text-muted)] text-caption'>
156-
{isFetchingNextPage
157-
? 'Loading more rows…'
158-
: `Showing ${rows.length} of ${totalCount} rows.`}
159-
</p>
160-
) : null}
161-
</div>
162-
)
73+
return <TableView source={tableSource} grants={grantsForShare('table')} host='panel' />
16374
}

apps/sim/components/resources/interface-view/components/module-renderer/components/table-module/components/table-cell-value/table-cell-value.test.ts renamed to apps/sim/components/resources/table-view/cell-formatting.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
/**
22
* @vitest-environment node
33
*
4-
* A table rendered inside an interface module must format its cells the same way
5-
* the tables grid does. Both surfaces resolve through the column-type registry
6-
* (`columnTypeOf(column).formatForDisplay`), so a currency column reads
7-
* `$1,234.50` in both places rather than `1234.5` in one of them, and a select
8-
* column shows its option *name* rather than the stored option id.
4+
* Every surface that draws a table resolves its cell text through the
5+
* column-type registry, so a currency column reads `$1,234.50` and a select
6+
* column shows its option *name* wherever it is mounted — the tables grid, an
7+
* embedded panel, or a public share.
98
*
10-
* These assert the formatting contract through the registry rather than through
11-
* the component, so they stay fast and cannot flake on rendering.
9+
* This pins the registry contract `CellContent` depends on. It regressed once:
10+
* the interface module carried its own resolver that handled only
11+
* boolean/null/json/date/string and let currency and select fall through to
12+
* `JSON.stringify`, so a module rendered `1234.5` and `opt_open` where the grid
13+
* rendered `$1,234.50` and `Open`.
1214
*/
1315
import { describe, expect, it } from 'vitest'
1416
import { columnTypeOf } from '@/lib/table/column-types'
@@ -18,7 +20,7 @@ function column(overrides: Partial<ColumnDefinition> & Pick<ColumnDefinition, 't
1820
return { id: 'col_1', name: 'col', ...overrides } as ColumnDefinition
1921
}
2022

21-
describe('interface table module cell formatting', () => {
23+
describe('table cell display formatting', () => {
2224
it('formats currency through the registry, not as a bare number', () => {
2325
const col = column({ type: 'currency', currencyCode: 'USD' })
2426
const text = columnTypeOf(col).formatForDisplay(1234.5, col)
@@ -56,8 +58,8 @@ describe('interface table module cell formatting', () => {
5658

5759
/**
5860
* The registry's completeness gate means every column type has a formatter;
59-
* this is what lets the module use one fallback branch instead of a per-type
60-
* switch that would drift from the grid's.
61+
* that is what lets the cell layer use one fallback branch instead of a
62+
* per-type switch that would drift from the grid's.
6163
*/
6264
it('gives every column type a display formatter', () => {
6365
for (const type of ['string', 'number', 'boolean', 'date', 'json', 'select', 'currency']) {

0 commit comments

Comments
 (0)