Skip to content

Commit b4cbd5e

Browse files
committed
feat(tables): preview referenced rows inline
1 parent 41c1281 commit b4cbd5e

52 files changed

Lines changed: 3882 additions & 235 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/table/[tableId]/columns/route.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({
5555
vi.mock('@/app/api/table/utils', () => ({
5656
accessError: () => new Response('denied', { status: 403 }),
5757
checkAccess: mockCheckAccess,
58+
orchestrationErrorResponse: (error: unknown) =>
59+
error instanceof OrchestrationError
60+
? NextResponse.json(
61+
{ error: error.message },
62+
{ status: statusForOrchestrationError(error.code) }
63+
)
64+
: null,
5865
orchestrationOutcomeErrorResponse: (
5966
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
6067
fallback: string
@@ -73,7 +80,7 @@ import {
7380
type OrchestrationErrorCode,
7481
statusForOrchestrationError,
7582
} from '@/lib/core/orchestration/types'
76-
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
83+
import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route'
7784

7885
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
7986

@@ -88,6 +95,49 @@ function patch(updates: Record<string, unknown>) {
8895
)
8996
}
9097

98+
function post(column: Record<string, unknown>) {
99+
return POST(
100+
new NextRequest('http://localhost/api/table/t1/columns', {
101+
method: 'POST',
102+
body: JSON.stringify({ workspaceId: WORKSPACE_ID, column }),
103+
headers: { 'content-type': 'application/json' },
104+
}),
105+
{ params: Promise.resolve({ tableId: 't1' }) }
106+
)
107+
}
108+
109+
describe('POST /api/table/[tableId]/columns — Reference feature gate', () => {
110+
beforeEach(() => {
111+
vi.clearAllMocks()
112+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
113+
success: true,
114+
userId: 'user-1',
115+
authType: 'session',
116+
})
117+
mockCheckAccess.mockResolvedValue({
118+
ok: true,
119+
table: { workspaceId: WORKSPACE_ID, schema: { columns: [] } },
120+
})
121+
})
122+
123+
it('returns 403 when Reference columns are disabled', async () => {
124+
mockAddTableColumn.mockRejectedValue(
125+
new OrchestrationError('forbidden', 'Reference columns are not enabled for this deployment')
126+
)
127+
128+
const response = await post({
129+
name: 'Account',
130+
type: 'reference',
131+
referenceTableId: 'tbl_accounts',
132+
})
133+
134+
expect(response.status).toBe(403)
135+
expect(await response.json()).toEqual({
136+
error: 'Reference columns are not enabled for this deployment',
137+
})
138+
})
139+
})
140+
91141
describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
92142
beforeEach(() => {
93143
vi.clearAllMocks()

apps/sim/app/api/table/[tableId]/columns/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire'
1717
import {
1818
accessError,
1919
checkAccess,
20+
orchestrationErrorResponse,
2021
orchestrationOutcomeErrorResponse,
2122
rootErrorMessage,
2223
tableLockErrorResponse,
@@ -69,6 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
6970
return validationErrorResponse(error, 'Invalid request data')
7071
}
7172

73+
const classified = orchestrationErrorResponse(error)
74+
if (classified) return classified
75+
7276
const msg = rootErrorMessage(error)
7377
if (
7478
msg.includes('already exists') ||

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
88
interface ComboboxOption {
99
label: string
1010
value: string
11+
disabled?: boolean
1112
}
1213

1314
interface ComboboxProps {
@@ -16,6 +17,7 @@ interface ComboboxProps {
1617
placeholder?: string
1718
searchable?: boolean
1819
searchPlaceholder?: string
20+
disabled?: boolean
1921
onChange?: (value: string) => void
2022
}
2123

@@ -143,6 +145,7 @@ describe('ColumnConfigSidebar', () => {
143145
existingColumn={null}
144146
workspaceId='workspace-1'
145147
tableId='table-current'
148+
referenceColumnsEnabled
146149
/>
147150
)
148151
})
@@ -179,6 +182,7 @@ describe('ColumnConfigSidebar', () => {
179182
existingColumn={null}
180183
workspaceId='workspace-1'
181184
tableId='table-current'
185+
referenceColumnsEnabled
182186
/>
183187
)
184188
})
@@ -206,6 +210,7 @@ describe('ColumnConfigSidebar', () => {
206210
workspaceId='workspace-1'
207211
tableId='table-current'
208212
onColumnRename={onColumnRename}
213+
referenceColumnsEnabled
209214
/>
210215
)
211216
})
@@ -227,6 +232,32 @@ describe('ColumnConfigSidebar', () => {
227232
expect(onColumnRename).toHaveBeenCalledWith('col-reference', 'Renamed relation')
228233
})
229234

235+
it('keeps an existing Reference column readable but not retargetable when disabled', async () => {
236+
await act(async () => {
237+
root.render(
238+
<ColumnConfigSidebar
239+
config={{ mode: 'edit', columnName: 'col-reference' }}
240+
onClose={vi.fn()}
241+
existingColumn={{
242+
id: 'col-reference',
243+
name: 'Related row',
244+
type: 'reference',
245+
referenceTableId: 'table-current',
246+
}}
247+
workspaceId='workspace-1'
248+
tableId='table-current'
249+
referenceColumnsEnabled={false}
250+
/>
251+
)
252+
})
253+
254+
expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false })
255+
expect(findCombobox('Select table')?.disabled).toBe(true)
256+
expect(findCombobox('Select type')?.options).toContainEqual(
257+
expect.objectContaining({ value: 'reference', disabled: true })
258+
)
259+
})
260+
230261
it('keeps Select options in the edit sidebar', async () => {
231262
await act(async () => {
232263
root.render(
@@ -241,6 +272,7 @@ describe('ColumnConfigSidebar', () => {
241272
}}
242273
workspaceId='workspace-1'
243274
tableId='table-current'
275+
referenceColumnsEnabled
244276
/>
245277
)
246278
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ interface ColumnConfigSidebarProps {
5959
tableId: string
6060
/** Notify the grid so local layout metadata follows a successful rename. */
6161
onColumnRename?: (oldName: string, newName: string) => void
62+
referenceColumnsEnabled: boolean
6263
}
6364

6465
/**
@@ -109,6 +110,7 @@ function ColumnConfigBody({
109110
workspaceId,
110111
tableId,
111112
onColumnRename,
113+
referenceColumnsEnabled,
112114
}: ColumnConfigBodyProps) {
113115
const updateColumn = useUpdateColumn({ workspaceId, tableId })
114116
const addColumn = useAddTableColumn({ workspaceId, tableId })
@@ -141,14 +143,20 @@ function ColumnConfigBody({
141143
const [optionsError, setOptionsError] = useState<string | null>(null)
142144
const [referenceTableError, setReferenceTableError] = useState<string | null>(null)
143145

144-
const saveDisabled = updateColumn.isPending || addColumn.isPending
145146
const trimmedName = nameInput.trim()
146147
const wantsOptions = isSelectType(typeInput)
147148
const wantsCurrency = typeInput === 'currency'
148149
const wantsReference = typeInput === 'reference'
150+
const referenceMutationBlocked =
151+
!referenceColumnsEnabled &&
152+
wantsReference &&
153+
(config.mode === 'create' ||
154+
existingColumn?.type !== 'reference' ||
155+
existingColumn.referenceTableId !== referenceTableInput)
156+
const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked
149157
const supportsUnique = columnTypeById(typeInput).supportsUnique
150158
const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', {
151-
enabled: wantsReference,
159+
enabled: wantsReference && referenceColumnsEnabled,
152160
})
153161
const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name }))
154162
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
@@ -303,12 +311,20 @@ function ColumnConfigBody({
303311
options={columnTypeOptionsForTable(allColumns, existingColumn, {
304312
tableRowTtlEnabled,
305313
})
306-
.filter((option) => option.type !== 'workflow')
314+
.filter(
315+
(option) =>
316+
option.type !== 'workflow' &&
317+
(referenceColumnsEnabled ||
318+
option.type !== 'reference' ||
319+
existingColumn?.type === 'reference')
320+
)
307321
.map((option) => ({
308322
label: option.label,
309323
value: option.type,
310324
icon: option.icon,
311-
disabled: option.disabledReason !== undefined,
325+
disabled:
326+
option.disabledReason !== undefined ||
327+
(!referenceColumnsEnabled && option.type === 'reference'),
312328
}))}
313329
value={typeInput}
314330
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
@@ -371,6 +387,7 @@ function ColumnConfigBody({
371387
<ChipCombobox
372388
options={tableOptions}
373389
value={referenceTableInput}
390+
disabled={!referenceColumnsEnabled}
374391
onChange={(value) => {
375392
setReferenceTableInput(value)
376393
if (referenceTableError) setReferenceTableError(null)

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ describe('ColumnDropdown', () => {
3333
tableRowTtlEnabled
3434
trigger='header'
3535
disabled={false}
36+
referenceColumnsEnabled
3637
onPickType={vi.fn()}
3738
onPickWorkflow={vi.fn()}
3839
onPickEnrichment={onPickEnrichment}
@@ -57,4 +58,31 @@ describe('ColumnDropdown', () => {
5758
act(() => items.at(-1)?.click())
5859
expect(onPickEnrichment).toHaveBeenCalledOnce()
5960
})
61+
62+
it('omits Reference when the feature is disabled', () => {
63+
act(() => {
64+
root.render(
65+
<ColumnDropdown
66+
trigger='header'
67+
disabled={false}
68+
referenceColumnsEnabled={false}
69+
onPickType={vi.fn()}
70+
onPickWorkflow={vi.fn()}
71+
onPickEnrichment={vi.fn()}
72+
blocked={false}
73+
onBlocked={vi.fn()}
74+
/>
75+
)
76+
})
77+
act(() => {
78+
container
79+
.querySelector<HTMLButtonElement>('button')
80+
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
81+
})
82+
83+
const labels = [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(
84+
(item) => item.textContent
85+
)
86+
expect(labels).not.toContain('Reference')
87+
})
6088
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface ColumnDropdownProps {
2727
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2828
trigger: 'header' | 'inline-header'
2929
disabled: boolean
30+
referenceColumnsEnabled: boolean
3031
onPickType: (type: ColumnDefinition['type']) => void
3132
onPickWorkflow: () => void
3233
onPickEnrichment: () => void
@@ -84,6 +85,7 @@ export function ColumnDropdown({
8485
tableRowTtlEnabled,
8586
trigger,
8687
disabled,
88+
referenceColumnsEnabled,
8789
onPickType,
8890
onPickWorkflow,
8991
onPickEnrichment,
@@ -126,13 +128,15 @@ export function ColumnDropdown({
126128
<DropdownMenu>
127129
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
128130
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
129-
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => {
130-
const onSelect =
131-
option.type === 'workflow'
132-
? onPickWorkflow
133-
: () => onPickType(option.type as ColumnDefinition['type'])
134-
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
135-
})}
131+
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled })
132+
.filter((option) => referenceColumnsEnabled || option.type !== 'reference')
133+
.map((option) => {
134+
const onSelect =
135+
option.type === 'workflow'
136+
? onPickWorkflow
137+
: () => onPickType(option.type as ColumnDefinition['type'])
138+
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
139+
})}
136140
<DropdownMenuItem onSelect={onPickEnrichment}>
137141
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
138142
Enrichments
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createTableColumn } from '@sim/testing'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import type { DisplayColumn } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types'
9+
10+
vi.mock(
11+
'@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render',
12+
() => ({
13+
resolveCellRender: () => ({ kind: 'empty' }),
14+
CellRender: () => null,
15+
})
16+
)
17+
18+
vi.mock(
19+
'@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors',
20+
() => ({ InlineEditor: () => <input data-testid='inline-editor' /> })
21+
)
22+
23+
import { CellContent } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content'
24+
25+
const COLUMN: DisplayColumn = {
26+
...createTableColumn({ id: 'col-name', name: 'Name', type: 'string' }),
27+
key: 'col-name',
28+
groupSize: 1,
29+
groupStartColIndex: 0,
30+
headerLabel: 'Name',
31+
isGroupStart: true,
32+
}
33+
34+
let container: HTMLDivElement
35+
let root: Root
36+
37+
beforeEach(() => {
38+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
39+
container = document.createElement('div')
40+
document.body.appendChild(container)
41+
act(() => {
42+
root = createRoot(container)
43+
})
44+
})
45+
46+
afterEach(() => {
47+
act(() => root.unmount())
48+
container.remove()
49+
})
50+
51+
describe('CellContent', () => {
52+
it('keeps the inline editor below the sticky table header', () => {
53+
act(() => {
54+
root.render(
55+
<CellContent
56+
value='Acme'
57+
column={COLUMN}
58+
workspaceId='workspace-1'
59+
isEditing
60+
onSave={vi.fn()}
61+
onCancel={vi.fn()}
62+
/>
63+
)
64+
})
65+
66+
const editorLayer = container.querySelector('[data-testid="inline-editor"]')?.parentElement
67+
expect(editorLayer?.className).toContain('z-[9]')
68+
expect(editorLayer?.className).not.toContain('z-10')
69+
})
70+
})

0 commit comments

Comments
 (0)