Skip to content

Commit b6a5594

Browse files
committed
feat(tables): preview referenced rows inline
1 parent 45e5b27 commit b6a5594

53 files changed

Lines changed: 4888 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

@@ -137,6 +139,7 @@ describe('ColumnConfigSidebar', () => {
137139
existingColumn={null}
138140
workspaceId='workspace-1'
139141
tableId='table-current'
142+
referenceColumnsEnabled
140143
/>
141144
)
142145
})
@@ -173,6 +176,7 @@ describe('ColumnConfigSidebar', () => {
173176
existingColumn={null}
174177
workspaceId='workspace-1'
175178
tableId='table-current'
179+
referenceColumnsEnabled
176180
/>
177181
)
178182
})
@@ -198,6 +202,7 @@ describe('ColumnConfigSidebar', () => {
198202
}}
199203
workspaceId='workspace-1'
200204
tableId='table-current'
205+
referenceColumnsEnabled
201206
/>
202207
)
203208
})
@@ -214,6 +219,32 @@ describe('ColumnConfigSidebar', () => {
214219
})
215220
})
216221

222+
it('keeps an existing Reference column readable but not retargetable when disabled', async () => {
223+
await act(async () => {
224+
root.render(
225+
<ColumnConfigSidebar
226+
config={{ mode: 'edit', columnName: 'col-reference' }}
227+
onClose={vi.fn()}
228+
existingColumn={{
229+
id: 'col-reference',
230+
name: 'Related row',
231+
type: 'reference',
232+
referenceTableId: 'table-current',
233+
}}
234+
workspaceId='workspace-1'
235+
tableId='table-current'
236+
referenceColumnsEnabled={false}
237+
/>
238+
)
239+
})
240+
241+
expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false })
242+
expect(findCombobox('Select table')?.disabled).toBe(true)
243+
expect(findCombobox('Select type')?.options).toContainEqual(
244+
expect.objectContaining({ value: 'reference', disabled: true })
245+
)
246+
})
247+
217248
it('keeps Select options in the edit sidebar', async () => {
218249
await act(async () => {
219250
root.render(
@@ -228,6 +259,7 @@ describe('ColumnConfigSidebar', () => {
228259
}}
229260
workspaceId='workspace-1'
230261
tableId='table-current'
262+
referenceColumnsEnabled
231263
/>
232264
)
233265
})

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
@@ -57,6 +57,7 @@ interface ColumnConfigSidebarProps {
5757
tableRowTtlEnabled: boolean
5858
workspaceId: string
5959
tableId: string
60+
referenceColumnsEnabled: boolean
6061
}
6162

6263
/**
@@ -106,6 +107,7 @@ function ColumnConfigBody({
106107
tableRowTtlEnabled,
107108
workspaceId,
108109
tableId,
110+
referenceColumnsEnabled,
109111
}: ColumnConfigBodyProps) {
110112
const updateColumn = useUpdateColumn({ workspaceId, tableId })
111113
const addColumn = useAddTableColumn({ workspaceId, tableId })
@@ -138,14 +140,20 @@ function ColumnConfigBody({
138140
const [optionsError, setOptionsError] = useState<string | null>(null)
139141
const [referenceTableError, setReferenceTableError] = useState<string | null>(null)
140142

141-
const saveDisabled = updateColumn.isPending || addColumn.isPending
142143
const trimmedName = nameInput.trim()
143144
const wantsOptions = isSelectType(typeInput)
144145
const wantsCurrency = typeInput === 'currency'
145146
const wantsReference = typeInput === 'reference'
147+
const referenceMutationBlocked =
148+
!referenceColumnsEnabled &&
149+
wantsReference &&
150+
(config.mode === 'create' ||
151+
existingColumn?.type !== 'reference' ||
152+
existingColumn.referenceTableId !== referenceTableInput)
153+
const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked
146154
const supportsUnique = columnTypeById(typeInput).supportsUnique
147155
const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', {
148-
enabled: wantsReference,
156+
enabled: wantsReference && referenceColumnsEnabled,
149157
})
150158
const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name }))
151159
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
@@ -298,12 +306,20 @@ function ColumnConfigBody({
298306
options={columnTypeOptionsForTable(allColumns, existingColumn, {
299307
tableRowTtlEnabled,
300308
})
301-
.filter((option) => option.type !== 'workflow')
309+
.filter(
310+
(option) =>
311+
option.type !== 'workflow' &&
312+
(referenceColumnsEnabled ||
313+
option.type !== 'reference' ||
314+
existingColumn?.type === 'reference')
315+
)
302316
.map((option) => ({
303317
label: option.label,
304318
value: option.type,
305319
icon: option.icon,
306-
disabled: option.disabledReason !== undefined,
320+
disabled:
321+
option.disabledReason !== undefined ||
322+
(!referenceColumnsEnabled && option.type === 'reference'),
307323
}))}
308324
value={typeInput}
309325
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
@@ -366,6 +382,7 @@ function ColumnConfigBody({
366382
<ChipCombobox
367383
options={tableOptions}
368384
value={referenceTableInput}
385+
disabled={!referenceColumnsEnabled}
369386
onChange={(value) => {
370387
setReferenceTableInput(value)
371388
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)