Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
# TABLE_REFERENCE_COLUMNS= # Table Reference columns
# KNOWLEDGE_MEMBER_ACCESS= # Per-member knowledge connectors and hybrid-by-default retrieval
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only

Expand Down
52 changes: 51 additions & 1 deletion apps/sim/app/api/table/[tableId]/columns/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ vi.mock('@/lib/table/wire', () => ({
vi.mock('@/app/api/table/utils', () => ({
accessError: () => new Response('denied', { status: 403 }),
checkAccess: mockCheckAccess,
orchestrationErrorResponse: (error: unknown) =>
error instanceof OrchestrationError
? NextResponse.json(
{ error: error.message },
{ status: statusForOrchestrationError(error.code) }
)
: null,
orchestrationOutcomeErrorResponse: (
outcome: { error?: string; errorCode?: OrchestrationErrorCode },
fallback: string
Expand All @@ -73,7 +80,7 @@ import {
type OrchestrationErrorCode,
statusForOrchestrationError,
} from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/table/[tableId]/columns/route'
import { PATCH, POST } from '@/app/api/table/[tableId]/columns/route'

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

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

function post(column: Record<string, unknown>) {
return POST(
new NextRequest('http://localhost/api/table/t1/columns', {
method: 'POST',
body: JSON.stringify({ workspaceId: WORKSPACE_ID, column }),
headers: { 'content-type': 'application/json' },
}),
{ params: Promise.resolve({ tableId: 't1' }) }
)
}

describe('POST /api/table/[tableId]/columns — Reference feature gate', () => {
beforeEach(() => {
vi.clearAllMocks()
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
success: true,
userId: 'user-1',
authType: 'session',
})
mockCheckAccess.mockResolvedValue({
ok: true,
table: { workspaceId: WORKSPACE_ID, schema: { columns: [] } },
})
})

it('returns 403 when Reference columns are disabled', async () => {
mockAddTableColumn.mockRejectedValue(
new OrchestrationError('forbidden', 'Reference columns are not enabled for this deployment')
)

const response = await post({
name: 'Account',
type: 'reference',
referenceTableId: 'tbl_accounts',
})

expect(response.status).toBe(403)
expect(await response.json()).toEqual({
error: 'Reference columns are not enabled for this deployment',
})
})
})

describe('PATCH /api/table/[tableId]/columns — pre-flight guards', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/app/api/table/[tableId]/columns/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { normalizeColumn } from '@/lib/table/wire'
import {
accessError,
checkAccess,
orchestrationErrorResponse,
orchestrationOutcomeErrorResponse,
rootErrorMessage,
tableLockErrorResponse,
Expand Down Expand Up @@ -69,6 +70,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Colum
return validationErrorResponse(error, 'Invalid request data')
}

const classified = orchestrationErrorResponse(error)
if (classified) return classified

const msg = rootErrorMessage(error)
if (
msg.includes('already exists') ||
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

interface ComboboxOption {
label: string
value: string
disabled?: boolean
}

interface ComboboxProps {
options: ComboboxOption[]
value?: string
placeholder?: string
searchable?: boolean
searchPlaceholder?: string
disabled?: boolean
onChange?: (value: string) => void
}

interface SelectOptionsEditorProps {
options: Array<{ id: string; name: string }>
onChange: (options: Array<{ id: string; name: string }>) => void
}

const {
capturedComboboxes,
capturedSelectEditor,
mockAddColumn,
mockUpdateColumn,
mockUseTablesList,
} = vi.hoisted(() => ({
capturedComboboxes: { current: [] as ComboboxProps[] },
capturedSelectEditor: { current: null as SelectOptionsEditorProps | null },
mockAddColumn: vi.fn(),
mockUpdateColumn: vi.fn(),
mockUseTablesList: vi.fn(),
}))

vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
<button {...props}>{children}</button>
),
ChipCombobox: (props: ComboboxProps) => {
capturedComboboxes.current.push(props)
return <div data-placeholder={props.placeholder} />
},
ChipInput: (props: React.InputHTMLAttributes<HTMLInputElement>) => <input {...props} />,
FieldDivider: () => <hr />,
Label: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Switch: ({ checked }: { checked?: boolean }) => (
<button type='button' aria-pressed={checked}>
Toggle
</button>
),
cn: (...values: Array<string | false | null | undefined>) => values.filter(Boolean).join(' '),
toast: { error: vi.fn(), success: vi.fn() },
}))

vi.mock('@sim/emcn/icons', () => ({
PlayOutline: () => <svg />,
X: () => <svg />,
}))

vi.mock('@/lib/table/column-types', () => ({
ALL_COLUMN_TYPES: [
{ id: 'string', label: 'Text', icon: () => null },
{ id: 'select', label: 'Select', icon: () => null },
{ id: 'reference', label: 'Reference', icon: () => null },
],
columnTypeById: (type: string) => ({ supportsUnique: type !== 'select' }),
}))

vi.mock('@/hooks/queries/tables', () => ({
useAddTableColumn: () => ({ isPending: false, mutateAsync: mockAddColumn }),
useTablesList: mockUseTablesList,
useUpdateColumn: () => ({ isPending: false, mutateAsync: mockUpdateColumn }),
}))

vi.mock('@/app/workspace/[workspaceId]/tables/[tableId]/components/select-field', () => ({
SelectOptionsEditor: (props: SelectOptionsEditorProps) => {
capturedSelectEditor.current = props
return <div data-testid='select-options-editor' />
},
}))

import { ColumnConfigSidebar } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar'

let container: HTMLDivElement
let root: Root

function findCombobox(placeholder: string): ComboboxProps | undefined {
return capturedComboboxes.current.find((combobox) => combobox.placeholder === placeholder)
}

function findButton(label: string): HTMLButtonElement | undefined {
return [...container.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent === label
)
}

beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
capturedComboboxes.current = []
capturedSelectEditor.current = null
mockUseTablesList.mockReturnValue({
data: [
{ id: 'table-current', name: 'Current table' },
{ id: 'table-customers', name: 'Customers' },
],
})
mockAddColumn.mockResolvedValue({ data: { columns: [] } })
mockUpdateColumn.mockResolvedValue({ data: { columns: [] } })
})

afterEach(() => {
act(() => root.unmount())
container.remove()
vi.clearAllMocks()
})

describe('ColumnConfigSidebar', () => {
it('creates a Reference column with the selected workspace table', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }}
onClose={vi.fn()}
existingColumn={null}
workspaceId='workspace-1'
tableId='table-current'
referenceColumnsEnabled
/>
)
})

expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: true })
expect(container.querySelector<HTMLInputElement>('#column-sidebar-name')?.value).toBe(
'Related row'
)
expect(findCombobox('Select table')).toMatchObject({
options: [
{ label: 'Current table', value: 'table-current' },
{ label: 'Customers', value: 'table-customers' },
],
searchable: true,
searchPlaceholder: 'Search tables',
})

act(() => findCombobox('Select table')?.onChange?.('table-customers'))
await act(async () => findButton('Save')?.click())

expect(mockAddColumn).toHaveBeenCalledWith({
name: 'Related row',
type: 'reference',
referenceTableId: 'table-customers',
})
})

it('keeps Reference creation open until a target table is selected', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
config={{ mode: 'create', proposedName: 'Related row', type: 'reference' }}
onClose={vi.fn()}
existingColumn={null}
workspaceId='workspace-1'
tableId='table-current'
referenceColumnsEnabled
/>
)
})

await act(async () => findButton('Save')?.click())

expect(container).toHaveTextContent('Select a table')
expect(mockAddColumn).not.toHaveBeenCalled()
expect(mockUpdateColumn).not.toHaveBeenCalled()
})

it('edits Reference configuration without exposing column renaming', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
config={{ mode: 'edit', columnName: 'col-reference' }}
onClose={vi.fn()}
existingColumn={{
id: 'col-reference',
name: 'Related row',
type: 'reference',
referenceTableId: 'table-current',
}}
workspaceId='workspace-1'
tableId='table-current'
referenceColumnsEnabled
/>
)
})

expect(container).not.toHaveTextContent('Column name')
expect(container.querySelector('#column-sidebar-name')).toBeNull()

act(() => findCombobox('Select table')?.onChange?.('table-customers'))
await act(async () => findButton('Save')?.click())

expect(mockUpdateColumn).toHaveBeenCalledWith({
columnName: 'col-reference',
updates: { referenceTableId: 'table-customers' },
})
})

it('keeps an existing Reference column visible but not retargetable when disabled', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
config={{ mode: 'edit', columnName: 'col-reference' }}
onClose={vi.fn()}
existingColumn={{
id: 'col-reference',
name: 'Related row',
type: 'reference',
referenceTableId: 'table-current',
}}
workspaceId='workspace-1'
tableId='table-current'
referenceColumnsEnabled={false}
/>
)
})

expect(mockUseTablesList).toHaveBeenCalledWith('workspace-1', 'active', { enabled: false })
expect(findCombobox('Select table')?.disabled).toBe(true)
expect(findCombobox('Select type')?.options).toContainEqual(
expect.objectContaining({ value: 'reference', disabled: true })
)
})

it('keeps Select options in the edit sidebar', async () => {
await act(async () => {
root.render(
<ColumnConfigSidebar
config={{ mode: 'edit', columnName: 'col-status' }}
onClose={vi.fn()}
existingColumn={{
id: 'col-status',
name: 'Status',
type: 'select',
options: [{ id: 'option-ready', name: 'Ready' }],
}}
workspaceId='workspace-1'
tableId='table-current'
referenceColumnsEnabled
/>
)
})

expect(container).toHaveTextContent('Options')
expect(container).toHaveTextContent('Multiselect')
act(() =>
capturedSelectEditor.current?.onChange([
{ id: 'option-ready', name: 'Ready' },
{ id: 'option-done', name: 'Done' },
])
)
await act(async () => findButton('Save')?.click())

expect(mockUpdateColumn).toHaveBeenCalledWith({
columnName: 'col-status',
updates: {
options: [
{ id: 'option-ready', name: 'Ready' },
{ id: 'option-done', name: 'Done' },
],
},
})
})
})
Loading