Skip to content

Commit fa3fff6

Browse files
committed
feat(tables): preview referenced rows inline
1 parent b830db1 commit fa3fff6

39 files changed

Lines changed: 3989 additions & 218 deletions
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { createMockRequest } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
getSession: vi.fn(),
10+
listNames: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
14+
vi.mock('@/lib/table/application/operations', () => ({
15+
tableOperations: { list: { id: 'tables.list' } },
16+
}))
17+
vi.mock('@/lib/table/application/tables', () => ({
18+
listTableNamesUseCase: { operation: { id: 'tables.list' }, execute: mocks.listNames },
19+
}))
20+
21+
import { POST } from '@/app/api/table/names/route'
22+
23+
function request(body?: unknown) {
24+
return createMockRequest('POST', body, {}, 'http://localhost/api/table/names')
25+
}
26+
27+
describe('POST /api/table/names', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
mocks.getSession.mockResolvedValue({
31+
user: { id: 'user-1' },
32+
session: { id: 'session-1' },
33+
})
34+
mocks.listNames.mockResolvedValue({
35+
tables: [{ id: 'table-1', name: 'Accounts' }],
36+
})
37+
})
38+
39+
it('returns the lightweight table-name projection', async () => {
40+
const response = await POST(
41+
request({ workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] }),
42+
{}
43+
)
44+
45+
expect(response.status).toBe(200)
46+
expect(mocks.listNames.mock.calls[0][0]).toMatchObject({
47+
principal: { kind: 'session', userId: 'user-1' },
48+
input: { workspaceId: 'workspace-1', tableIds: ['table-1', 'table-2'] },
49+
})
50+
expect(await response.json()).toEqual({
51+
success: true,
52+
data: { tables: [{ id: 'table-1', name: 'Accounts' }] },
53+
})
54+
})
55+
56+
it('authenticates before validating the body', async () => {
57+
mocks.getSession.mockResolvedValue(null)
58+
59+
const response = await POST(request(), {})
60+
61+
expect(response.status).toBe(401)
62+
expect(mocks.listNames).not.toHaveBeenCalled()
63+
})
64+
65+
it('rejects an empty table ID list', async () => {
66+
const response = await POST(request({ workspaceId: 'workspace-1', tableIds: [] }), {})
67+
68+
expect(response.status).toBe(400)
69+
expect(mocks.listNames).not.toHaveBeenCalled()
70+
})
71+
})
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { listTableNamesContract } from '@/lib/api/contracts/tables'
2+
import {
3+
defineInternalJsonRoute,
4+
internalOrchestrationErrorPolicy,
5+
internalRateLimits,
6+
internalSessionAuth,
7+
} from '@/lib/api/server/routes'
8+
import { tableOperations } from '@/lib/table/application/operations'
9+
import { listTableNamesUseCase } from '@/lib/table/application/tables'
10+
11+
export const POST = defineInternalJsonRoute({
12+
contract: listTableNamesContract,
13+
operation: tableOperations.list,
14+
auth: internalSessionAuth,
15+
rateLimit: internalRateLimits.none({
16+
reason: 'Preserve existing internal table list behavior',
17+
}),
18+
errorPolicy: internalOrchestrationErrorPolicy,
19+
mapInput: ({ body }) => body,
20+
useCase: listTableNamesUseCase,
21+
present: ({ tables }) => ({ success: true as const, data: { tables } }),
22+
})

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
'use client'
22

33
import type { RowExecutionMetadata } from '@/lib/table'
4+
import {
5+
CellRender,
6+
type ReferenceCellAction,
7+
resolveCellRender,
8+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
49
import type { TimezoneState } from '@/hooks/queries/general-settings'
510
import type { SaveReason } from '../../../types'
611
import type { DisplayColumn } from '../types'
7-
import { CellRender, resolveCellRender } from './cell-render'
812
import { InlineEditor } from './inline-editors'
913

1014
interface CellContentProps {
@@ -15,6 +19,7 @@ interface CellContentProps {
1519
* URL render as a tagged-resource chip instead of a plain external link. */
1620
workspaceId: string
1721
timezoneStatus: TimezoneState['status']
22+
referenceColumnsEnabled: boolean
1823
isEditing: boolean
1924
initialCharacter?: string | null
2025
/** Opens the inline editor read-only; text stays selectable and copyable. */
@@ -29,6 +34,7 @@ interface CellContentProps {
2934
waitingOnLabels?: string[]
3035
/** Column is an enrichment output — a completed-but-empty cell renders "Not found". */
3136
isEnrichmentOutput?: boolean
37+
referenceAction?: ReferenceCellAction
3238
}
3339

3440
/**
@@ -43,13 +49,15 @@ export function CellContent({
4349
column,
4450
workspaceId,
4551
timezoneStatus,
52+
referenceColumnsEnabled,
4653
isEditing,
4754
initialCharacter,
4855
readOnly,
4956
onSave,
5057
onCancel,
5158
waitingOnLabels,
5259
isEnrichmentOutput,
60+
referenceAction,
5361
}: CellContentProps) {
5462
const kind = resolveCellRender({
5563
value,
@@ -59,6 +67,7 @@ export function CellContent({
5967
isEnrichmentOutput,
6068
currentWorkspaceId: workspaceId,
6169
timezoneStatus,
70+
referenceColumnsEnabled,
6271
})
6372

6473
return (
@@ -75,7 +84,7 @@ export function CellContent({
7584
/>
7685
</div>
7786
)}
78-
<CellRender kind={kind} isEditing={isEditing} />
87+
<CellRender kind={kind} isEditing={isEditing} referenceAction={referenceAction} />
7988
</>
8089
)
8190
}

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ describe('resolveCellRender', () => {
3232
exec: undefined,
3333
column: column('ttl'),
3434
waitingOnLabels: undefined,
35+
referenceColumnsEnabled: false,
3536
timezoneStatus,
3637
})
3738
expect(kind).toEqual({ kind: 'text', text: value })
@@ -48,6 +49,7 @@ describe('resolveCellRender', () => {
4849
exec: undefined,
4950
column: column('date'),
5051
waitingOnLabels: undefined,
52+
referenceColumnsEnabled: false,
5153
timezoneStatus: 'error',
5254
})
5355
expect(kind).toEqual({ kind: 'date', text: stored, raw: true })
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
8+
9+
vi.mock('@sim/emcn', () => ({
10+
Badge: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
11+
Button: ({
12+
children,
13+
size,
14+
variant,
15+
...props
16+
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
17+
size?: string
18+
variant?: string
19+
}) => (
20+
<button data-size={size} data-variant={variant} {...props}>
21+
{children}
22+
</button>
23+
),
24+
Checkbox: () => null,
25+
ChipTag: ({
26+
children,
27+
variant,
28+
...props
29+
}: React.HTMLAttributes<HTMLSpanElement> & { variant?: string }) => (
30+
<span data-chip-tag-variant={variant} {...props}>
31+
{children}
32+
</span>
33+
),
34+
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
35+
Tooltip: {
36+
Root: ({ children }: { children: React.ReactNode }) => children,
37+
Trigger: ({ children }: { children: React.ReactNode }) => children,
38+
Content: ({ children }: { children: React.ReactNode }) => children,
39+
},
40+
}))
41+
42+
vi.mock('@/app/workspace/[workspaceId]/logs/utils', () => ({
43+
StatusBadge: () => null,
44+
}))
45+
46+
vi.mock(
47+
'@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/sim-resource-cell',
48+
() => ({ SimResourceCell: () => null })
49+
)
50+
51+
vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({
52+
resolveSelectOptions: () => [],
53+
SelectPill: () => null,
54+
}))
55+
56+
import {
57+
CellRender,
58+
resolveCellRender,
59+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render'
60+
61+
const REFERENCE_COLUMN: DisplayColumn = {
62+
id: 'col-account',
63+
key: 'col-account',
64+
name: 'Account',
65+
type: 'reference',
66+
referenceTableId: 'table-accounts',
67+
referenceTableName: 'Accounts',
68+
groupSize: 1,
69+
groupStartColIndex: 0,
70+
headerLabel: 'Account',
71+
isGroupStart: true,
72+
}
73+
74+
let container: HTMLDivElement
75+
let root: Root
76+
77+
beforeEach(() => {
78+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
79+
container = document.createElement('div')
80+
document.body.appendChild(container)
81+
act(() => {
82+
root = createRoot(container)
83+
})
84+
})
85+
86+
afterEach(() => {
87+
act(() => root.unmount())
88+
container.remove()
89+
})
90+
91+
describe('reference cell rendering', () => {
92+
it('resolves a stored row ID to a chip labeled with the referenced table name', () => {
93+
expect(
94+
resolveCellRender({
95+
value: 'row-account-1',
96+
exec: undefined,
97+
column: REFERENCE_COLUMN,
98+
waitingOnLabels: undefined,
99+
referenceColumnsEnabled: true,
100+
})
101+
).toEqual({ kind: 'reference-chip', label: 'Accounts' })
102+
})
103+
104+
it('keeps an empty reference cell empty', () => {
105+
expect(
106+
resolveCellRender({
107+
value: '',
108+
exec: undefined,
109+
column: REFERENCE_COLUMN,
110+
waitingOnLabels: undefined,
111+
referenceColumnsEnabled: true,
112+
})
113+
).toEqual({ kind: 'empty' })
114+
})
115+
116+
it('uses a neutral label while the referenced table name is unavailable', () => {
117+
expect(
118+
resolveCellRender({
119+
value: 'row-account-1',
120+
exec: undefined,
121+
column: { ...REFERENCE_COLUMN, referenceTableName: undefined },
122+
waitingOnLabels: undefined,
123+
referenceColumnsEnabled: true,
124+
})
125+
).toEqual({ kind: 'reference-chip', label: 'Referenced table' })
126+
})
127+
128+
it('renders the stored row ID as plain text when the feature is disabled', () => {
129+
expect(
130+
resolveCellRender({
131+
value: 'row-account-1',
132+
exec: undefined,
133+
column: REFERENCE_COLUMN,
134+
waitingOnLabels: undefined,
135+
referenceColumnsEnabled: false,
136+
})
137+
).toEqual({ kind: 'text', text: 'row-account-1' })
138+
})
139+
140+
it('opens the referenced row from the chip without exposing its stored row ID', () => {
141+
const onReferenceClick = vi.fn()
142+
143+
act(() => {
144+
root.render(
145+
<CellRender
146+
kind={resolveCellRender({
147+
value: 'row-account-1',
148+
exec: undefined,
149+
column: REFERENCE_COLUMN,
150+
waitingOnLabels: undefined,
151+
referenceColumnsEnabled: true,
152+
})}
153+
isEditing={false}
154+
referenceAction={{ expanded: false, onClick: onReferenceClick }}
155+
/>
156+
)
157+
})
158+
159+
const chip = container.querySelector('button')
160+
expect(chip?.textContent).toBe('Accounts')
161+
expect(chip?.dataset.variant).toBe('ghost')
162+
expect(chip?.dataset.size).toBe('sm')
163+
expect(chip).toHaveProperty('dataset.referenceCellTrigger', '')
164+
expect(chip?.className).toContain('max-w-full')
165+
expect(chip?.className).toContain('p-0')
166+
expect(chip?.querySelector('svg')).toBeNull()
167+
const tag = chip?.querySelector('[data-chip-tag-variant="field"]')
168+
expect(tag?.textContent).toBe('Accounts')
169+
expect(tag?.className).toContain('min-w-0')
170+
expect(tag?.className).toContain('max-w-full')
171+
172+
act(() => chip?.click())
173+
174+
expect(onReferenceClick).toHaveBeenCalledOnce()
175+
expect(container.textContent).not.toContain('row-account-1')
176+
})
177+
178+
it('keeps a chip double-click from reaching the reference cell', () => {
179+
const onCellDoubleClick = vi.fn()
180+
const onReferenceClick = vi.fn()
181+
182+
act(() => {
183+
root.render(
184+
<div onDoubleClick={onCellDoubleClick}>
185+
<CellRender
186+
kind={{ kind: 'reference-chip', label: 'Accounts' }}
187+
isEditing={false}
188+
referenceAction={{ expanded: false, onClick: onReferenceClick }}
189+
/>
190+
</div>
191+
)
192+
})
193+
194+
act(() => {
195+
const chip = container.querySelector('button')
196+
chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 1 }))
197+
chip?.dispatchEvent(new MouseEvent('click', { bubbles: true, detail: 2 }))
198+
chip?.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, detail: 2 }))
199+
})
200+
201+
expect(onReferenceClick).toHaveBeenCalledOnce()
202+
expect(onCellDoubleClick).not.toHaveBeenCalled()
203+
})
204+
})

0 commit comments

Comments
 (0)