Skip to content

Commit 9e80171

Browse files
j15zclaude
andcommitted
fix(tables): explain denied actions where the user meets them
Clicking a column header opened the full column editor on a schema-locked table: every field was editable and Save only failed once the server refused it. The header click is the primary way into that panel, so it now opens read-only — values stay readable and selectable, a disabled `<fieldset>` makes the controls inert, and Save carries the lock reason. Clicking a checkbox cell on an update-locked table did nothing at all, while the keyboard paths explained themselves; it now raises the same notice. The column menu disabled only "Edit column" while "Insert column left/right" and "Delete column" stayed live and explained the lock after the click. All four are disabled now, each with a tooltip. A disabled `DropdownMenuItem` sets `pointer-events: none`, so the tooltip wraps the row rather than the item. "Hide column" is untouched: hiding a workflow output is a metadata change no lock covers. Notices now speak the modal's Allow/Deny vocabulary and name the row that denies the action, and the tooltip strings live in `lock-copy` instead of being written out at each call site. Drops the "This table is append-only" copy, which no path could reach once New row and Shift+Enter started opening the add-row form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent fe76390 commit 9e80171

9 files changed

Lines changed: 314 additions & 173 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx‎

Lines changed: 142 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
'use client'
22

33
import { useState } from 'react'
4-
import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn'
4+
import {
5+
Button,
6+
ChipCombobox,
7+
ChipInput,
8+
cn,
9+
FieldDivider,
10+
Label,
11+
Switch,
12+
Tooltip,
13+
toast,
14+
} from '@sim/emcn'
515
import { X } from '@sim/emcn/icons'
616
import { toError } from '@sim/utils/errors'
717
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
@@ -59,6 +69,15 @@ interface ColumnConfigSidebarProps {
5969
/** Notify parent of a rename so it can rewrite local `columnOrder` /
6070
* `columnWidths` keys that reference the old name. */
6171
onColumnRename?: (oldName: string, newName: string) => void
72+
/**
73+
* Opens the panel for reading only — every field is inert and Save is
74+
* disabled behind {@link readOnlyReason}. The header click that opens this
75+
* sidebar is a primary affordance, so a schema-locked (or read-only) table
76+
* shows the column's settings rather than swallowing the click.
77+
*/
78+
readOnly?: boolean
79+
/** Why saving is unavailable; surfaced on the disabled Save button. */
80+
readOnlyReason?: string
6281
}
6382

6483
/**
@@ -109,6 +128,8 @@ function ColumnConfigBody({
109128
workspaceId,
110129
tableId,
111130
onColumnRename,
131+
readOnly,
132+
readOnlyReason,
112133
}: ColumnConfigBodyProps) {
113134
const updateColumn = useUpdateColumn({ workspaceId, tableId })
114135
const addColumn = useAddTableColumn({ workspaceId, tableId })
@@ -154,6 +175,8 @@ function ColumnConfigBody({
154175
}
155176

156177
async function handleSave() {
178+
// Belt and braces: the button is disabled, and the server refuses too.
179+
if (readOnly) return
157180
if (!trimmedName) {
158181
setShowValidation(true)
159182
return
@@ -254,118 +277,136 @@ function ColumnConfigBody({
254277
</div>
255278

256279
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'>
257-
<div className='flex flex-col gap-[9.5px]'>
258-
<RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel>
259-
<ChipInput
260-
id='column-sidebar-name'
261-
value={nameInput}
262-
onChange={(e) => {
263-
setNameInput(e.target.value)
264-
if (nameError) setNameError(null)
265-
}}
266-
spellCheck={false}
267-
autoComplete='off'
268-
error={Boolean((showValidation && !trimmedName) || nameError)}
269-
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
270-
/>
271-
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
272-
{nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />}
273-
</div>
274-
275-
{config.mode === 'edit' && (
276-
<>
277-
<FieldDivider />
278-
<div className='flex flex-col gap-[9.5px]'>
279-
<RequiredLabel>Type</RequiredLabel>
280-
<ChipCombobox
281-
options={columnTypeOptionsForTable(allColumns, existingColumn, {
282-
tableRowTtlEnabled,
283-
})
284-
.filter((option) => option.type !== 'workflow')
285-
.map((option) => ({
286-
label: option.label,
287-
value: option.type,
288-
icon: option.icon,
289-
disabled: option.disabledReason !== undefined,
290-
}))}
291-
value={typeInput}
292-
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
293-
placeholder='Select type'
294-
maxHeight={300}
295-
/>
296-
</div>
297-
</>
298-
)}
280+
{/* `disabled` on the fieldset reaches every native control inside,
281+
including the comboboxes' trigger buttons; `contents` keeps the
282+
existing layout. Values stay readable and selectable. */}
283+
<fieldset disabled={readOnly} className='contents'>
284+
<div className='flex flex-col gap-[9.5px]'>
285+
<RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel>
286+
<ChipInput
287+
id='column-sidebar-name'
288+
value={nameInput}
289+
onChange={(e) => {
290+
setNameInput(e.target.value)
291+
if (nameError) setNameError(null)
292+
}}
293+
spellCheck={false}
294+
autoComplete='off'
295+
error={Boolean((showValidation && !trimmedName) || nameError)}
296+
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
297+
/>
298+
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
299+
{nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />}
300+
</div>
299301

300-
{wantsCurrency && (
301-
<>
302-
<FieldDivider />
303-
<div className='flex flex-col gap-[9.5px]'>
304-
<RequiredLabel>Currency</RequiredLabel>
305-
<ChipCombobox
306-
options={CURRENCY_COMBOBOX_OPTIONS}
307-
value={currencyInput}
308-
onChange={setCurrencyInput}
309-
placeholder='Select currency'
310-
searchable
311-
searchPlaceholder='Search currencies'
312-
maxHeight={260}
313-
/>
314-
</div>
315-
</>
316-
)}
302+
{config.mode === 'edit' && (
303+
<>
304+
<FieldDivider />
305+
<div className='flex flex-col gap-[9.5px]'>
306+
<RequiredLabel>Type</RequiredLabel>
307+
<ChipCombobox
308+
options={columnTypeOptionsForTable(allColumns, existingColumn, {
309+
tableRowTtlEnabled,
310+
})
311+
.filter((option) => option.type !== 'workflow')
312+
.map((option) => ({
313+
label: option.label,
314+
value: option.type,
315+
icon: option.icon,
316+
disabled: option.disabledReason !== undefined,
317+
}))}
318+
value={typeInput}
319+
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
320+
placeholder='Select type'
321+
maxHeight={300}
322+
/>
323+
</div>
324+
</>
325+
)}
317326

318-
{wantsOptions && (
319-
<>
320-
<FieldDivider />
321-
<div className='flex flex-col gap-[9.5px]'>
322-
<RequiredLabel>Options</RequiredLabel>
323-
<SelectOptionsEditor
324-
options={optionsInput}
325-
onChange={(next) => {
326-
setOptionsInput(next)
327-
if (optionsError) setOptionsError(null)
328-
}}
329-
/>
330-
{optionsError && <FieldError message={optionsError} />}
331-
</div>
332-
<FieldDivider />
333-
<div className='flex items-center justify-between pl-0.5'>
334-
<Label htmlFor='column-sidebar-multiple'>Multiselect</Label>
335-
<Switch
336-
id='column-sidebar-multiple'
337-
checked={multipleInput}
338-
onCheckedChange={(v) => setMultipleInput(!!v)}
339-
/>
340-
</div>
341-
</>
342-
)}
327+
{wantsCurrency && (
328+
<>
329+
<FieldDivider />
330+
<div className='flex flex-col gap-[9.5px]'>
331+
<RequiredLabel>Currency</RequiredLabel>
332+
<ChipCombobox
333+
options={CURRENCY_COMBOBOX_OPTIONS}
334+
value={currencyInput}
335+
onChange={setCurrencyInput}
336+
placeholder='Select currency'
337+
searchable
338+
searchPlaceholder='Search currencies'
339+
maxHeight={260}
340+
/>
341+
</div>
342+
</>
343+
)}
343344

344-
{/* Select columns don't expose a unique constraint. */}
345-
{!wantsOptions && (
346-
<>
347-
<FieldDivider />
348-
<div className='flex flex-col gap-[9.5px]'>
345+
{wantsOptions && (
346+
<>
347+
<FieldDivider />
348+
<div className='flex flex-col gap-[9.5px]'>
349+
<RequiredLabel>Options</RequiredLabel>
350+
<SelectOptionsEditor
351+
options={optionsInput}
352+
onChange={(next) => {
353+
setOptionsInput(next)
354+
if (optionsError) setOptionsError(null)
355+
}}
356+
/>
357+
{optionsError && <FieldError message={optionsError} />}
358+
</div>
359+
<FieldDivider />
349360
<div className='flex items-center justify-between pl-0.5'>
350-
<Label htmlFor='column-sidebar-unique'>Unique</Label>
361+
<Label htmlFor='column-sidebar-multiple'>Multiselect</Label>
351362
<Switch
352-
id='column-sidebar-unique'
353-
checked={uniqueInput}
354-
onCheckedChange={(v) => setUniqueInput(!!v)}
363+
id='column-sidebar-multiple'
364+
checked={multipleInput}
365+
onCheckedChange={(v) => setMultipleInput(!!v)}
355366
/>
356367
</div>
357-
</div>
358-
</>
359-
)}
368+
</>
369+
)}
370+
371+
{/* Select columns don't expose a unique constraint. */}
372+
{!wantsOptions && (
373+
<>
374+
<FieldDivider />
375+
<div className='flex flex-col gap-[9.5px]'>
376+
<div className='flex items-center justify-between pl-0.5'>
377+
<Label htmlFor='column-sidebar-unique'>Unique</Label>
378+
<Switch
379+
id='column-sidebar-unique'
380+
checked={uniqueInput}
381+
onCheckedChange={(v) => setUniqueInput(!!v)}
382+
/>
383+
</div>
384+
</div>
385+
</>
386+
)}
387+
</fieldset>
360388
</div>
361389

362390
<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
363391
<Button variant='default' size='sm' onClick={onClose}>
364-
Cancel
365-
</Button>
366-
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
367-
{saveDisabled ? 'Saving…' : 'Save'}
392+
{readOnly ? 'Close' : 'Cancel'}
368393
</Button>
394+
{readOnly ? (
395+
<Tooltip.Root>
396+
<Tooltip.Trigger asChild>
397+
<span className='inline-flex'>
398+
<Button variant='primary' size='sm' disabled>
399+
Save
400+
</Button>
401+
</span>
402+
</Tooltip.Trigger>
403+
{readOnlyReason && <Tooltip.Content>{readOnlyReason}</Tooltip.Content>}
404+
</Tooltip.Root>
405+
) : (
406+
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
407+
{saveDisabled ? 'Saving…' : 'Save'}
408+
</Button>
409+
)}
369410
</div>
370411
</div>
371412
)

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type ColumnTypeOption,
1919
columnTypeOptionsForTable,
2020
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar'
21+
import { LOCK_TOOLTIPS } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
2122

2223
const CELL_HEADER =
2324
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
@@ -117,7 +118,7 @@ export function ColumnDropdown({
117118
const lockedTrigger = (
118119
<Tooltip.Root>
119120
<Tooltip.Trigger asChild>{triggerButton}</Tooltip.Trigger>
120-
<Tooltip.Content>Changing the table schema is disabled in Table Security.</Tooltip.Content>
121+
<Tooltip.Content>{LOCK_TOOLTIPS.schema}</Tooltip.Content>
121122
</Tooltip.Root>
122123
)
123124
return trigger === 'inline-header' ? (

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ describe('ExpandedCellPopover', () => {
120120
expect(getTextarea().readOnly).toBe(true)
121121
expect(getSaveButton().disabled).toBe(true)
122122
expect(document.body.textContent).not.toContain(BLOCKED_REASON)
123+
// The ↵ half of the shortcut hint would advertise a save that never happens.
124+
expect(document.body.textContent).toContain('esc close')
125+
expect(document.body.textContent).not.toContain('save ·')
123126

124127
const trigger = getSaveButton().parentElement
125128
if (!trigger) throw new Error('Missing Save tooltip trigger')
@@ -135,6 +138,7 @@ describe('ExpandedCellPopover', () => {
135138
render()
136139
expect(getTextarea().readOnly).toBe(false)
137140
expect(getSaveButton().disabled).toBe(false)
141+
expect(document.body.textContent).toContain('save ·')
138142

139143
typeDraft('Changed text')
140144
pressEnter()

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,11 @@ function ExpandedCellEditor({
278278
<div className='flex items-center justify-between border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
279279
{parseError ? (
280280
<span className='text-[var(--text-error)] text-caption'>{parseError}</span>
281+
) : saveBlockedReason ? (
282+
// Saving is refused, so the ↵ half of the shortcut hint would be a lie.
283+
<span className='text-[var(--text-tertiary)] text-caption'>
284+
<kbd className='font-mono'>esc</kbd> close
285+
</span>
281286
) : (
282287
<span className='text-[var(--text-tertiary)] text-caption'>
283288
<kbd className='font-mono'>↵</kbd> save · <kbd className='font-mono'>esc</kbd> cancel

‎apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ interface ColumnHeaderMenuProps {
1515
column: DisplayColumn
1616
colIndex: number
1717
readOnly?: boolean
18-
schemaLocked?: boolean
18+
/** Why column changes are unavailable; disables the schema rows and explains them. */
19+
schemaLockedReason?: string
20+
/** Why deleting is unavailable; disables the destructive column row. */
21+
deleteLockedReason?: string
1922
isRenaming: boolean
2023
isColumnSelected: boolean
2124
renameValue: string
@@ -66,7 +69,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
6669
column,
6770
colIndex,
6871
readOnly,
69-
schemaLocked,
72+
schemaLockedReason,
73+
deleteLockedReason,
7074
isRenaming,
7175
isColumnSelected,
7276
renameValue,
@@ -348,7 +352,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
348352
column={column}
349353
deleteLabel={deleteLabel}
350354
onOpenConfig={onOpenConfig}
351-
schemaLocked={schemaLocked}
355+
schemaLockedReason={schemaLockedReason}
356+
deleteLockedReason={deleteLockedReason}
352357
onInsertLeft={onInsertLeft}
353358
onInsertRight={onInsertRight}
354359
onDeleteColumn={onDeleteColumn}

0 commit comments

Comments
 (0)