Skip to content

Commit 96b1962

Browse files
committed
feat(tables): gate reference columns
1 parent a25eb83 commit 96b1962

27 files changed

Lines changed: 406 additions & 22 deletions

File tree

‎apps/sim/.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
215215
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
216216
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
217217
# PERMISSION_ACCESS_REQUESTS_ENABLED= # Global access-request rollout; organizations may opt out
218+
# TABLE_REFERENCE_COLUMNS= # Table Reference columns
218219
# KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval
219220
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
220221

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,49 @@ function patch(updates: Record<string, unknown>) {
9595
)
9696
}
9797

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+
98141
describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
99142
beforeEach(() => {
100143
vi.clearAllMocks()

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
7070
return validationErrorResponse(error, 'Invalid request data')
7171
}
7272

73+
const classified = orchestrationErrorResponse(error)
74+
if (classified) return classified
75+
7376
const msg = rootErrorMessage(error)
7477
if (
7578
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
@@ -65,6 +65,7 @@ interface ColumnConfigSidebarProps {
6565
existingColumn: ColumnDefinition | null
6666
allColumns: readonly ColumnDefinition[]
6767
tableRowTtlEnabled: boolean
68+
referenceColumnsEnabled: boolean
6869
workspaceId: string
6970
tableId: string
7071
/**
@@ -126,6 +127,7 @@ function ColumnConfigBody({
126127
existingColumn,
127128
allColumns,
128129
tableRowTtlEnabled,
130+
referenceColumnsEnabled,
129131
workspaceId,
130132
tableId,
131133
readOnly,
@@ -163,14 +165,20 @@ function ColumnConfigBody({
163165
const [optionsError, setOptionsError] = useState<string | null>(null)
164166
const [referenceTableError, setReferenceTableError] = useState<string | null>(null)
165167

166-
const saveDisabled = updateColumn.isPending || addColumn.isPending
167168
const trimmedName = nameInput.trim()
168169
const wantsOptions = isSelectType(typeInput)
169170
const wantsCurrency = typeInput === 'currency'
170171
const wantsReference = typeInput === 'reference'
172+
const referenceMutationBlocked =
173+
!referenceColumnsEnabled &&
174+
wantsReference &&
175+
(config.mode === 'create' ||
176+
existingColumn?.type !== 'reference' ||
177+
existingColumn.referenceTableId !== referenceTableInput)
178+
const saveDisabled = updateColumn.isPending || addColumn.isPending || referenceMutationBlocked
171179
const supportsUnique = columnTypeById(typeInput).supportsUnique
172180
const { data: workspaceTables = [] } = useTablesList(workspaceId, 'active', {
173-
enabled: wantsReference,
181+
enabled: wantsReference && referenceColumnsEnabled,
174182
})
175183
const tableOptions = workspaceTables.map((table) => ({ value: table.id, label: table.name }))
176184
const trimmedOptions = optionsInput.map((o) => ({ ...o, name: o.name.trim() }))
@@ -331,12 +339,20 @@ function ColumnConfigBody({
331339
options={columnTypeOptionsForTable(allColumns, existingColumn, {
332340
tableRowTtlEnabled,
333341
})
334-
.filter((option) => option.type !== 'workflow')
342+
.filter(
343+
(option) =>
344+
option.type !== 'workflow' &&
345+
(referenceColumnsEnabled ||
346+
option.type !== 'reference' ||
347+
existingColumn?.type === 'reference')
348+
)
335349
.map((option) => ({
336350
label: option.label,
337351
value: option.type,
338352
icon: option.icon,
339-
disabled: option.disabledReason !== undefined,
353+
disabled:
354+
option.disabledReason !== undefined ||
355+
(!referenceColumnsEnabled && option.type === 'reference'),
340356
}))}
341357
value={typeInput}
342358
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
@@ -399,6 +415,7 @@ function ColumnConfigBody({
399415
<ChipCombobox
400416
options={tableOptions}
401417
value={referenceTableInput}
418+
disabled={!referenceColumnsEnabled}
402419
onChange={(value) => {
403420
setReferenceTableInput(value)
404421
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
@@ -63,6 +63,7 @@ describe('ColumnDropdown', () => {
6363
tableRowTtlEnabled
6464
trigger='header'
6565
disabled={false}
66+
referenceColumnsEnabled
6667
onPickType={vi.fn()}
6768
onPickWorkflow={vi.fn()}
6869
onPickEnrichment={onPickEnrichment}
@@ -86,4 +87,33 @@ describe('ColumnDropdown', () => {
8687
act(() => items.at(-1)?.click())
8788
expect(onPickEnrichment).toHaveBeenCalledOnce()
8889
})
90+
91+
it('omits Reference when the feature is disabled', () => {
92+
act(() => {
93+
root.render(
94+
<ColumnDropdown
95+
columns={[]}
96+
tableRowTtlEnabled
97+
trigger='header'
98+
disabled={false}
99+
referenceColumnsEnabled={false}
100+
onPickType={vi.fn()}
101+
onPickWorkflow={vi.fn()}
102+
onPickEnrichment={vi.fn()}
103+
blocked={false}
104+
onBlocked={vi.fn()}
105+
/>
106+
)
107+
})
108+
act(() => {
109+
container
110+
.querySelector<HTMLButtonElement>('button')
111+
?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }))
112+
})
113+
114+
const labels = [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].map(
115+
(item) => item.textContent
116+
)
117+
expect(labels).not.toContain('Reference')
118+
})
89119
})

‎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
@@ -30,6 +30,7 @@ interface ColumnDropdownProps {
3030
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
3131
trigger: 'header' | 'inline-header'
3232
disabled: boolean
33+
referenceColumnsEnabled: boolean
3334
onPickType: (type: ColumnDefinition['type']) => void
3435
onPickWorkflow: () => void
3536
onPickEnrichment: () => void
@@ -81,6 +82,7 @@ export function ColumnDropdown({
8182
tableRowTtlEnabled,
8283
trigger,
8384
disabled,
85+
referenceColumnsEnabled,
8486
onPickType,
8587
onPickWorkflow,
8688
onPickEnrichment,
@@ -132,13 +134,15 @@ export function ColumnDropdown({
132134
<DropdownMenu>
133135
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
134136
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
135-
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => {
136-
const onSelect =
137-
option.type === 'workflow'
138-
? onPickWorkflow
139-
: () => onPickType(option.type as ColumnDefinition['type'])
140-
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
141-
})}
137+
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled })
138+
.filter((option) => referenceColumnsEnabled || option.type !== 'reference')
139+
.map((option) => {
140+
const onSelect =
141+
option.type === 'workflow'
142+
? onPickWorkflow
143+
: () => onPickType(option.type as ColumnDefinition['type'])
144+
return <ColumnTypeMenuItem key={option.type} option={option} onSelect={onSelect} />
145+
})}
142146
<DropdownMenuItem onSelect={onPickEnrichment}>
143147
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
144148
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
@@ -174,6 +174,7 @@ export interface SelectionSnapshot {
174174
interface TableGridProps {
175175
workspaceId?: string
176176
tableId?: string
177+
referenceColumnsEnabled: boolean
177178
embedded?: boolean
178179
tableRowTtlEnabled: boolean
179180
/** Remote collaborators' cell selections, rendered as presence overlays. */
@@ -449,6 +450,7 @@ async function chunkBatchUpdates(
449450
export function TableGrid({
450451
workspaceId: propWorkspaceId,
451452
tableId: propTableId,
453+
referenceColumnsEnabled,
452454
embedded,
453455
tableRowTtlEnabled,
454456
remoteSelections,
@@ -4970,7 +4972,9 @@ export function TableGrid({
49704972
deleteLockedReason={
49714973
locks?.deleteLocked ? LOCK_TOOLTIPS.delete : undefined
49724974
}
4973-
onGoToReferenceTable={handleGoToReferenceTable}
4975+
onGoToReferenceTable={
4976+
referenceColumnsEnabled ? handleGoToReferenceTable : undefined
4977+
}
49744978
onViewWorkflow={handleViewWorkflow}
49754979
onSortColumn={onSortColumn}
49764980
onClearSort={onClearSort}
@@ -4990,6 +4994,7 @@ export function TableGrid({
49904994
tableRowTtlEnabled={tableRowTtlEnabled}
49914995
trigger='inline-header'
49924996
disabled={addColumnMutation.isPending}
4997+
referenceColumnsEnabled={referenceColumnsEnabled}
49934998
blocked={!canMutateSchema}
49944999
onPickType={handleAddColumnOfType}
49955000
onPickWorkflow={handleAddWorkflowColumn}

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presen
4040
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
4141
import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
4242
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
43+
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
4344
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
4445
import {
4546
getTableViewRevision,
@@ -201,6 +202,8 @@ export function Table({
201202
const router = useRouter()
202203
const workspaceId = propWorkspaceId || (params.workspaceId as string)
203204
const tableId = propTableId || (params.tableId as string)
205+
const hostContext = useOptionalWorkspaceHostContext()
206+
const referenceColumnsEnabled = hostContext?.features?.referenceColumns ?? false
204207

205208
const posthog = usePostHog()
206209
const tableRowTtlEnabled = useFeatureFlag('table-row-ttl')
@@ -1440,6 +1443,7 @@ export function Table({
14401443
tableRowTtlEnabled={tableRowTtlEnabled}
14411444
trigger='header'
14421445
disabled={false}
1446+
referenceColumnsEnabled={referenceColumnsEnabled}
14431447
blocked={!canMutateSchema}
14441448
onPickType={handleAddColumnOfType}
14451449
onPickWorkflow={handleAddWorkflowColumn}
@@ -1602,6 +1606,7 @@ export function Table({
16021606
<TableGrid
16031607
workspaceId={workspaceId}
16041608
tableId={tableId}
1609+
referenceColumnsEnabled={referenceColumnsEnabled}
16051610
embedded={embedded}
16061611
tableRowTtlEnabled={tableRowTtlEnabled}
16071612
locks={tableData?.locks}
@@ -1711,6 +1716,7 @@ export function Table({
17111716
<ColumnConfigSidebar
17121717
config={columnConfig}
17131718
tableRowTtlEnabled={tableRowTtlEnabled}
1719+
referenceColumnsEnabled={referenceColumnsEnabled}
17141720
onClose={onCloseSlideout}
17151721
allColumns={columns}
17161722
existingColumn={

0 commit comments

Comments
 (0)