Skip to content

Commit 2c9ba97

Browse files
committed
feat(tables): gate reference columns
1 parent 42d9b94 commit 2c9ba97

27 files changed

Lines changed: 414 additions & 23 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
203203
# FORKING_ENABLED= # Workspace forks
204204
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
205205
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
206+
# TABLE_REFERENCE_COLUMNS= # Table Reference columns
206207
# KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval
207208
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
208209

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 visible 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
@@ -55,6 +55,7 @@ interface ColumnConfigSidebarProps {
5555
existingColumn: ColumnDefinition | null
5656
allColumns: readonly ColumnDefinition[]
5757
tableRowTtlEnabled: boolean
58+
referenceColumnsEnabled: boolean
5859
workspaceId: string
5960
tableId: string
6061
/** Notify parent of a rename so it can rewrite local `columnOrder` /
@@ -107,6 +108,7 @@ function ColumnConfigBody({
107108
existingColumn,
108109
allColumns,
109110
tableRowTtlEnabled,
111+
referenceColumnsEnabled,
110112
workspaceId,
111113
tableId,
112114
onColumnRename,
@@ -142,14 +144,20 @@ function ColumnConfigBody({
142144
const [optionsError, setOptionsError] = useState<string | null>(null)
143145
const [referenceTableError, setReferenceTableError] = useState<string | null>(null)
144146

145-
const saveDisabled = updateColumn.isPending || addColumn.isPending
146147
const trimmedName = nameInput.trim()
147148
const wantsOptions = isSelectType(typeInput)
148149
const wantsCurrency = typeInput === 'currency'
149150
const wantsReference = typeInput === 'reference'
151+
const referenceMutationBlocked =
152+
!referenceColumnsEnabled &&
153+
wantsReference &&
154+
(config.mode === 'create' ||
155+
existingColumn?.type !== 'reference' ||
156+
existingColumn.referenceTableId !== referenceTableInput)
157+
const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked
150158
const supportsUnique = columnTypeById(typeInput).supportsUnique
151159
const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', {
152-
enabled: wantsReference,
160+
enabled: wantsReference && referenceColumnsEnabled,
153161
})
154162
const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name }))
155163
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
@@ -304,12 +312,20 @@ function ColumnConfigBody({
304312
options={columnTypeOptionsForTable(allColumns, existingColumn, {
305313
tableRowTtlEnabled,
306314
})
307-
.filter((option) => option.type !== 'workflow')
315+
.filter(
316+
(option) =>
317+
option.type !== 'workflow' &&
318+
(referenceColumnsEnabled ||
319+
option.type !== 'reference' ||
320+
existingColumn?.type === 'reference')
321+
)
308322
.map((option) => ({
309323
label: option.label,
310324
value: option.type,
311325
icon: option.icon,
312-
disabled: option.disabledReason !== undefined,
326+
disabled:
327+
option.disabledReason !== undefined ||
328+
(!referenceColumnsEnabled && option.type === 'reference'),
313329
}))}
314330
value={typeInput}
315331
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
@@ -372,6 +388,7 @@ function ColumnConfigBody({
372388
<ChipCombobox
373389
options={tableOptions}
374390
value={referenceTableInput}
391+
disabled={!referenceColumnsEnabled}
375392
onChange={(value) => {
376393
setReferenceTableInput(value)
377394
if (referenceTableError) setReferenceTableError(null)

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

Lines changed: 30 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,33 @@ 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+
columns={[]}
67+
tableRowTtlEnabled
68+
trigger='header'
69+
disabled={false}
70+
referenceColumnsEnabled={false}
71+
onPickType={vi.fn()}
72+
onPickWorkflow={vi.fn()}
73+
onPickEnrichment={vi.fn()}
74+
blocked={false}
75+
onBlocked={vi.fn()}
76+
/>
77+
)
78+
})
79+
act(() => {
80+
container
81+
.querySelector<HTMLButtonElement>('button')
82+
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
83+
})
84+
85+
const labels = [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(
86+
(item) => item.textContent
87+
)
88+
expect(labels).not.toContain('Reference')
89+
})
6090
})

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

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ export interface SelectionSnapshot {
172172
interface TableGridProps {
173173
workspaceId?: string
174174
tableId?: string
175+
referenceColumnsEnabled: boolean
175176
embedded?: boolean
176177
tableRowTtlEnabled: boolean
177178
/** Remote collaborators' cell selections, rendered as presence overlays. */
@@ -436,6 +437,7 @@ async function chunkBatchUpdates(
436437
export function TableGrid({
437438
workspaceId: propWorkspaceId,
438439
tableId: propTableId,
440+
referenceColumnsEnabled,
439441
embedded,
440442
tableRowTtlEnabled,
441443
remoteSelections,
@@ -4904,7 +4906,9 @@ export function TableGrid({
49044906
workflowGroups={tableWorkflowGroups}
49054907
sourceInfo={columnSourceInfo.get(column.key)}
49064908
onOpenConfig={handleConfigureColumn}
4907-
onGoToReferenceTable={handleGoToReferenceTable}
4909+
onGoToReferenceTable={
4910+
referenceColumnsEnabled ? handleGoToReferenceTable : undefined
4911+
}
49084912
onViewWorkflow={handleViewWorkflow}
49094913
onSortColumn={onSortColumn}
49104914
onClearSort={onClearSort}
@@ -4924,6 +4928,7 @@ export function TableGrid({
49244928
tableRowTtlEnabled={tableRowTtlEnabled}
49254929
trigger='inline-header'
49264930
disabled={addColumnMutation.isPending}
4931+
referenceColumnsEnabled={referenceColumnsEnabled}
49274932
blocked={!canMutateSchema}
49284933
onBlocked={() => onBlockedAction('add-column')}
49294934
onPickType={handleAddColumnOfType}

0 commit comments

Comments
 (0)