Skip to content

Commit f911b7d

Browse files
committed
fix(tables): block schema-locked column edits and rework lock settings as Table Security
1 parent e21130b commit f911b7d

12 files changed

Lines changed: 536 additions & 131 deletions

File tree

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@ afterEach(() => {
2323
})
2424

2525
describe('ColumnDropdown', () => {
26+
it('keeps a schema-locked trigger focusable for its explanation without opening a menu', () => {
27+
const onPickType = vi.fn()
28+
act(() => {
29+
root.render(
30+
<ColumnDropdown
31+
columns={[]}
32+
tableRowTtlEnabled
33+
trigger='header'
34+
disabled={false}
35+
blocked
36+
onPickType={onPickType}
37+
onPickWorkflow={vi.fn()}
38+
onPickEnrichment={vi.fn()}
39+
/>
40+
)
41+
})
42+
const trigger = container.querySelector<HTMLButtonElement>('button')!
43+
expect(trigger.getAttribute('aria-disabled')).toBe('true')
44+
expect(trigger.disabled).toBe(false)
45+
act(() => {
46+
trigger.focus()
47+
trigger.click()
48+
})
49+
expect(document.querySelector('[role="tooltip"]')?.textContent).toContain(
50+
'Changing the table schema is disabled in Table Security.'
51+
)
52+
expect(document.querySelector('[role="menu"]')).toBeNull()
53+
expect(onPickType).not.toHaveBeenCalled()
54+
})
55+
2656
it('lists Enrichments as a regular entry after the column options', () => {
2757
const onPickEnrichment = vi.fn()
2858

@@ -37,7 +67,6 @@ describe('ColumnDropdown', () => {
3767
onPickWorkflow={vi.fn()}
3868
onPickEnrichment={onPickEnrichment}
3969
blocked={false}
40-
onBlocked={vi.fn()}
4170
/>
4271
)
4372
})

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

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@ import {
1010
DropdownMenuContent,
1111
DropdownMenuItem,
1212
DropdownMenuTrigger,
13-
Plus,
1413
Tooltip,
1514
} from '@sim/emcn'
16-
import { Sparkles } from '@sim/emcn/icons'
15+
import { Lock, Plus, Sparkles } from '@sim/emcn/icons'
1716
import type { ColumnDefinition } from '@/lib/table'
18-
import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar'
17+
import {
18+
type ColumnTypeOption,
19+
columnTypeOptionsForTable,
20+
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar'
1921

2022
const CELL_HEADER =
2123
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
@@ -30,14 +32,8 @@ interface ColumnDropdownProps {
3032
onPickType: (type: ColumnDefinition['type']) => void
3133
onPickWorkflow: () => void
3234
onPickEnrichment: () => void
33-
/**
34-
* When true, the trigger stays visible and clickable but opens nothing — it
35-
* calls {@link onBlocked} instead. Used when the table is schema-locked:
36-
* hiding the control leaves the user guessing, so it stays and explains.
37-
* Paired required so `blocked` can never be set without a handler.
38-
*/
35+
/** A schema lock disables the action and explains why on hover or focus. */
3936
blocked: boolean
40-
onBlocked: () => void
4137
}
4238

4339
interface ColumnTypeMenuItemProps {
@@ -88,37 +84,46 @@ export function ColumnDropdown({
8884
onPickWorkflow,
8985
onPickEnrichment,
9086
blocked,
91-
onBlocked,
9287
}: ColumnDropdownProps) {
88+
const Icon = blocked ? Lock : Plus
9389
const triggerButton =
9490
trigger === 'header' ? (
9591
<button
9692
type='button'
97-
className={chipVariants()}
93+
className={cn(chipVariants(), blocked && 'cursor-not-allowed opacity-60')}
9894
disabled={disabled}
99-
onClick={blocked ? onBlocked : undefined}
95+
aria-disabled={blocked || undefined}
10096
>
101-
<Plus className={chipContentIconClass} />
97+
<Icon className={chipContentIconClass} />
10298
<span className={chipContentLabelClass}>New column</span>
10399
<ChipChevronDown />
104100
</button>
105101
) : (
106102
<button
107103
type='button'
108-
className='flex h-[20px] cursor-pointer items-center gap-2 outline-hidden'
104+
className={cn(
105+
'flex h-[20px] items-center gap-2 outline-hidden',
106+
blocked ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
107+
)}
109108
disabled={disabled}
110-
onClick={blocked ? onBlocked : undefined}
109+
aria-disabled={blocked || undefined}
111110
>
112-
<Plus className='size-[14px] shrink-0 text-[var(--text-icon)]' />
111+
<Icon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
113112
<span className='text-[var(--text-body)] text-small'>New column</span>
114113
</button>
115114
)
116115

117116
if (blocked) {
117+
const lockedTrigger = (
118+
<Tooltip.Root>
119+
<Tooltip.Trigger asChild>{triggerButton}</Tooltip.Trigger>
120+
<Tooltip.Content>Changing the table schema is disabled in Table Security.</Tooltip.Content>
121+
</Tooltip.Root>
122+
)
118123
return trigger === 'inline-header' ? (
119-
<th className={CELL_HEADER}>{triggerButton}</th>
124+
<th className={CELL_HEADER}>{lockedTrigger}</th>
120125
) : (
121-
triggerButton
126+
lockedTrigger
122127
)
123128
}
124129

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { type TableLocks, UNLOCKED_TABLE_LOCKS } from '@/lib/table/types'
8+
import { LockSettingsModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal'
9+
import { useTableSecurityStore } from '@/stores/table/security/store'
10+
11+
const { mutateAsync } = vi.hoisted(() => ({ mutateAsync: vi.fn() }))
12+
vi.mock('@/hooks/queries/tables', () => ({
13+
useUpdateTableLocks: () => ({ mutateAsync, isPending: false }),
14+
}))
15+
16+
const LABELS = ['Inserting Rows', 'Updating Rows', 'Deleting Rows', 'Changing Table Schema']
17+
let container: HTMLDivElement
18+
let root: Root
19+
const onClose = vi.fn()
20+
21+
function render(locks: TableLocks = UNLOCKED_TABLE_LOCKS, isOpen = true) {
22+
act(() => {
23+
root.render(
24+
<LockSettingsModal
25+
isOpen={isOpen}
26+
onClose={onClose}
27+
workspaceId='workspace-1'
28+
tableId='table-1'
29+
locks={locks}
30+
/>
31+
)
32+
})
33+
}
34+
35+
function getSwitch(label: string): HTMLButtonElement {
36+
const element = document.querySelector<HTMLButtonElement>(
37+
`button[role="switch"][aria-label="${label}"]`
38+
)
39+
if (!element) throw new Error(`Missing switch: ${label}`)
40+
return element
41+
}
42+
43+
function clickSwitch(label: string) {
44+
act(() => getSwitch(label).click())
45+
}
46+
47+
function getPermission(label: string, choice: 'Deny' | 'Allow'): HTMLButtonElement {
48+
const group = document.querySelector(`[role="radiogroup"][aria-label="${label}"]`)
49+
const button = [
50+
...(group?.querySelectorAll<HTMLButtonElement>('button[role="radio"]') ?? []),
51+
].find((element) => element.textContent === choice)
52+
if (!button) throw new Error(`Missing permission: ${label} ${choice}`)
53+
return button
54+
}
55+
56+
function selectPermission(label: string, choice: 'Deny' | 'Allow') {
57+
act(() => getPermission(label, choice).click())
58+
}
59+
60+
function save() {
61+
const button = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
62+
(element) => element.textContent === 'Save'
63+
)
64+
if (!button) throw new Error('Missing Save button')
65+
act(() => button.click())
66+
}
67+
68+
beforeEach(() => {
69+
globalThis.IS_REACT_ACT_ENVIRONMENT = true
70+
vi.clearAllMocks()
71+
mutateAsync.mockReturnValue(new Promise(() => {}))
72+
useTableSecurityStore.getState().reset()
73+
container = document.createElement('div')
74+
document.body.appendChild(container)
75+
root = createRoot(container)
76+
})
77+
78+
afterEach(() => {
79+
act(() => root.unmount())
80+
container.remove()
81+
})
82+
83+
describe('Table Security', () => {
84+
it('hides permissions while disabled and enables all four backend locks by default', () => {
85+
render()
86+
expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
87+
expect(document.querySelector('[role="radiogroup"]')).toBeNull()
88+
89+
clickSwitch('Enable Table Security')
90+
for (const label of LABELS) {
91+
expect(getPermission(label, 'Deny').disabled).toBe(false)
92+
expect(getPermission(label, 'Allow').disabled).toBe(false)
93+
expect(getPermission(label, 'Deny').getAttribute('aria-checked')).toBe('true')
94+
expect(getPermission(label, 'Allow').getAttribute('aria-checked')).toBe('false')
95+
}
96+
save()
97+
98+
expect(mutateAsync.mock.calls[0][0]).toEqual({
99+
tableId: 'table-1',
100+
locks: { insertLocked: true, updateLocked: true, deleteLocked: true, schemaLocked: true },
101+
})
102+
})
103+
104+
it('inverts existing locks and remembers permissions after disabling, saving, and reopening', async () => {
105+
render({ insertLocked: true, updateLocked: true, deleteLocked: false, schemaLocked: true })
106+
expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('true')
107+
expect(getPermission('Deleting Rows', 'Allow').getAttribute('aria-checked')).toBe('true')
108+
expect(getPermission('Updating Rows', 'Deny').getAttribute('aria-checked')).toBe('true')
109+
110+
selectPermission('Inserting Rows', 'Allow')
111+
clickSwitch('Enable Table Security')
112+
expect(document.querySelector('[role="radiogroup"]')).toBeNull()
113+
let resolveSave!: () => void
114+
mutateAsync.mockReturnValueOnce(
115+
new Promise<void>((resolve) => {
116+
resolveSave = resolve
117+
})
118+
)
119+
save()
120+
expect(mutateAsync.mock.calls[0][0]).toEqual({
121+
tableId: 'table-1',
122+
locks: UNLOCKED_TABLE_LOCKS,
123+
})
124+
await act(async () => resolveSave())
125+
126+
render(UNLOCKED_TABLE_LOCKS, false)
127+
render()
128+
expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
129+
expect(document.querySelector('[role="radiogroup"]')).toBeNull()
130+
clickSwitch('Enable Table Security')
131+
expect(getPermission('Inserting Rows', 'Allow').getAttribute('aria-checked')).toBe('true')
132+
expect(getPermission('Updating Rows', 'Deny').getAttribute('aria-checked')).toBe('true')
133+
save()
134+
expect(mutateAsync.mock.calls[1][0]).toEqual({
135+
tableId: 'table-1',
136+
locks: { insertLocked: false, updateLocked: true, deleteLocked: false, schemaLocked: true },
137+
})
138+
})
139+
140+
it('does not remember unsuccessful changes and discards them on reopen', () => {
141+
render()
142+
clickSwitch('Enable Table Security')
143+
selectPermission('Inserting Rows', 'Allow')
144+
save()
145+
expect(useTableSecurityStore.getState().preferences['table-1']).toBeUndefined()
146+
expect(onClose).not.toHaveBeenCalled()
147+
148+
render(UNLOCKED_TABLE_LOCKS, false)
149+
render()
150+
expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
151+
expect(document.querySelector('[role="radiogroup"]')).toBeNull()
152+
clickSwitch('Enable Table Security')
153+
expect(getPermission('Inserting Rows', 'Deny').getAttribute('aria-checked')).toBe('true')
154+
})
155+
})

0 commit comments

Comments
 (0)