diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx index 87b813c62ba..c2d80f94d36 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -1,7 +1,17 @@ 'use client' import { useState } from 'react' -import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn' +import { + Button, + ChipCombobox, + ChipInput, + cn, + FieldDivider, + Label, + Switch, + Tooltip, + toast, +} from '@sim/emcn' import { X } from '@sim/emcn/icons' import { toError } from '@sim/utils/errors' import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' @@ -59,6 +69,15 @@ interface ColumnConfigSidebarProps { /** Notify parent of a rename so it can rewrite local `columnOrder` / * `columnWidths` keys that reference the old name. */ onColumnRename?: (oldName: string, newName: string) => void + /** + * Opens the panel for reading only — every field is inert and Save is + * disabled behind {@link readOnlyReason}. The header click that opens this + * sidebar is a primary affordance, so a schema-locked (or read-only) table + * shows the column's settings rather than swallowing the click. + */ + readOnly?: boolean + /** Why saving is unavailable; surfaced on the disabled Save button. */ + readOnlyReason?: string } /** @@ -109,6 +128,8 @@ function ColumnConfigBody({ workspaceId, tableId, onColumnRename, + readOnly, + readOnlyReason, }: ColumnConfigBodyProps) { const updateColumn = useUpdateColumn({ workspaceId, tableId }) const addColumn = useAddTableColumn({ workspaceId, tableId }) @@ -154,6 +175,8 @@ function ColumnConfigBody({ } async function handleSave() { + // Belt and braces: the button is disabled, and the server refuses too. + if (readOnly) return if (!trimmedName) { setShowValidation(true) return @@ -254,118 +277,136 @@ function ColumnConfigBody({
-
- Column name - { - setNameInput(e.target.value) - if (nameError) setNameError(null) - }} - spellCheck={false} - autoComplete='off' - error={Boolean((showValidation && !trimmedName) || nameError)} - aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} - /> - {showValidation && !trimmedName && } - {nameError && !(showValidation && !trimmedName) && } -
- - {config.mode === 'edit' && ( - <> - -
- Type - option.type !== 'workflow') - .map((option) => ({ - label: option.label, - value: option.type, - icon: option.icon, - disabled: option.disabledReason !== undefined, - }))} - value={typeInput} - onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} - placeholder='Select type' - maxHeight={300} - /> -
- - )} + {/* `disabled` on the fieldset reaches every native control inside, + including the comboboxes' trigger buttons; `contents` keeps the + existing layout. Values stay readable and selectable. */} +
+
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + error={Boolean((showValidation && !trimmedName) || nameError)} + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
- {wantsCurrency && ( - <> - -
- Currency - -
- - )} + {config.mode === 'edit' && ( + <> + +
+ Type + option.type !== 'workflow') + .map((option) => ({ + label: option.label, + value: option.type, + icon: option.icon, + disabled: option.disabledReason !== undefined, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={300} + /> +
+ + )} - {wantsOptions && ( - <> - -
- Options - { - setOptionsInput(next) - if (optionsError) setOptionsError(null) - }} - /> - {optionsError && } -
- -
- - setMultipleInput(!!v)} - /> -
- - )} + {wantsCurrency && ( + <> + +
+ Currency + +
+ + )} - {/* Select columns don't expose a unique constraint. */} - {!wantsOptions && ( - <> - -
+ {wantsOptions && ( + <> + +
+ Options + { + setOptionsInput(next) + if (optionsError) setOptionsError(null) + }} + /> + {optionsError && } +
+
- + setUniqueInput(!!v)} + id='column-sidebar-multiple' + checked={multipleInput} + onCheckedChange={(v) => setMultipleInput(!!v)} />
-
- - )} + + )} + + {/* Select columns don't expose a unique constraint. */} + {!wantsOptions && ( + <> + +
+
+ + setUniqueInput(!!v)} + /> +
+
+ + )} +
- + {readOnly ? ( + + + + + + + {readOnlyReason && {readOnlyReason}} + + ) : ( + + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx index e4321a7eb59..c2e5bcb3886 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.test.tsx @@ -23,6 +23,36 @@ afterEach(() => { }) describe('ColumnDropdown', () => { + it('keeps a schema-locked trigger focusable for its explanation without opening a menu', () => { + const onPickType = vi.fn() + act(() => { + root.render( + + ) + }) + const trigger = container.querySelector('button')! + expect(trigger.getAttribute('aria-disabled')).toBe('true') + expect(trigger.disabled).toBe(false) + act(() => { + trigger.focus() + trigger.click() + }) + expect(document.querySelector('[role="tooltip"]')?.textContent).toContain( + 'Changing the table schema is disabled in Table Security.' + ) + expect(document.querySelector('[role="menu"]')).toBeNull() + expect(onPickType).not.toHaveBeenCalled() + }) + it('lists Enrichments as a regular entry after the column options', () => { const onPickEnrichment = vi.fn() @@ -37,7 +67,6 @@ describe('ColumnDropdown', () => { onPickWorkflow={vi.fn()} onPickEnrichment={onPickEnrichment} blocked={false} - onBlocked={vi.fn()} /> ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx index 1f4ab32cef4..4fc8addc7ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx @@ -10,12 +10,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - Plus, Tooltip, } from '@sim/emcn' -import { Sparkles } from '@sim/emcn/icons' +import { Lock, Plus, Sparkles } from '@sim/emcn/icons' import type { ColumnDefinition } from '@/lib/table' -import { type ColumnTypeOption, columnTypeOptionsForTable } from '../column-config-sidebar' +import { + type ColumnTypeOption, + columnTypeOptionsForTable, +} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar' +import { LOCK_TOOLTIPS } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' @@ -30,14 +33,8 @@ interface ColumnDropdownProps { onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void - /** - * When true, the trigger stays visible and clickable but opens nothing — it - * calls {@link onBlocked} instead. Used when the table is schema-locked: - * hiding the control leaves the user guessing, so it stays and explains. - * Paired required so `blocked` can never be set without a handler. - */ + /** A schema lock disables the action and explains why on hover or focus. */ blocked: boolean - onBlocked: () => void } interface ColumnTypeMenuItemProps { @@ -88,37 +85,46 @@ export function ColumnDropdown({ onPickWorkflow, onPickEnrichment, blocked, - onBlocked, }: ColumnDropdownProps) { + const Icon = blocked ? Lock : Plus const triggerButton = trigger === 'header' ? ( ) : ( ) if (blocked) { + const lockedTrigger = ( + + {triggerButton} + {LOCK_TOOLTIPS.schema} + + ) return trigger === 'inline-header' ? ( - {triggerButton} + {lockedTrigger} ) : ( - triggerButton + lockedTrigger ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index 3bdc488b998..343c91d44c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -65,8 +65,7 @@ interface ContextMenuProps { disableInsert?: boolean /** * Duplicate is a one-shot insert carrying the copied row's data, so it needs - * only the insert lock — unlike the blank-row inserts above it, which also - * need the update lock to be fillable. + * only the insert lock. */ disableDuplicate?: boolean disableDelete?: boolean diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.test.tsx new file mode 100644 index 00000000000..029a34b05af --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.test.tsx @@ -0,0 +1,148 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { type TableLocks, UNLOCKED_TABLE_LOCKS } from '@/lib/table/types' +import { LockSettingsModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal' + +const { mutateAsync } = vi.hoisted(() => ({ mutateAsync: vi.fn() })) +vi.mock('@/hooks/queries/tables', () => ({ + useUpdateTableLocks: () => ({ mutateAsync, isPending: false }), +})) + +const LABELS = ['Inserting Rows', 'Updating Rows', 'Deleting Rows', 'Changing Table Schema'] +let container: HTMLDivElement +let root: Root +const onClose = vi.fn() + +function render(locks: TableLocks = UNLOCKED_TABLE_LOCKS, isOpen = true) { + act(() => { + root.render( + + ) + }) +} + +function getPermission(label: string, choice: 'Deny' | 'Allow'): HTMLButtonElement { + const group = document.querySelector(`[role="radiogroup"][aria-label="${label}"]`) + const button = [ + ...(group?.querySelectorAll('button[role="radio"]') ?? []), + ].find((element) => element.textContent === choice) + if (!button) throw new Error(`Missing permission: ${label} ${choice}`) + return button +} + +function selectPermission(label: string, choice: 'Deny' | 'Allow') { + act(() => getPermission(label, choice).click()) +} + +function getSave(): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find( + (element) => element.textContent === 'Save' + ) + if (!button) throw new Error('Missing Save button') + return button +} + +function save() { + act(() => getSave().click()) +} + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + vi.clearAllMocks() + mutateAsync.mockReturnValue(new Promise(() => {})) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('Table Security', () => { + it('always shows the four rows and starts an unconfigured table on Allow', () => { + render() + for (const label of LABELS) { + expect(getPermission(label, 'Allow').getAttribute('aria-checked')).toBe('true') + expect(getPermission(label, 'Deny').getAttribute('aria-checked')).toBe('false') + } + // Nothing staged yet, so there is nothing to save. + expect(getSave().disabled).toBe(true) + }) + + it('mirrors the server locks, with Deny meaning a set lock', () => { + render({ insertLocked: true, updateLocked: false, deleteLocked: true, schemaLocked: false }) + expect(getPermission('Inserting Rows', 'Deny').getAttribute('aria-checked')).toBe('true') + expect(getPermission('Deleting Rows', 'Deny').getAttribute('aria-checked')).toBe('true') + expect(getPermission('Updating Rows', 'Allow').getAttribute('aria-checked')).toBe('true') + expect(getPermission('Changing Table Schema', 'Allow').getAttribute('aria-checked')).toBe( + 'true' + ) + }) + + it('saves only the rows the admin moved', () => { + render() + selectPermission('Inserting Rows', 'Deny') + selectPermission('Changing Table Schema', 'Deny') + expect(getSave().disabled).toBe(false) + save() + + // A partial patch: the untouched rows are absent, so a concurrent change to + // one of them survives this save. + expect(mutateAsync.mock.calls[0][0]).toEqual({ + tableId: 'table-1', + locks: { insertLocked: true, schemaLocked: true }, + }) + }) + + it('follows a lock changed elsewhere while open without staging it', () => { + render() + selectPermission('Inserting Rows', 'Deny') + + // Another admin denies updates while this modal is open; the realtime + // refetch lands as a new `locks` prop. + render({ ...UNLOCKED_TABLE_LOCKS, updateLocked: true }) + expect(getPermission('Updating Rows', 'Deny').getAttribute('aria-checked')).toBe('true') + expect(getPermission('Inserting Rows', 'Deny').getAttribute('aria-checked')).toBe('true') + + save() + expect(mutateAsync.mock.calls[0][0]).toEqual({ + tableId: 'table-1', + locks: { insertLocked: true }, + }) + }) + + it('treats a row already matching the server as nothing to save', () => { + render({ ...UNLOCKED_TABLE_LOCKS, deleteLocked: true }) + selectPermission('Deleting Rows', 'Allow') + expect(getSave().disabled).toBe(false) + selectPermission('Deleting Rows', 'Deny') + expect(getSave().disabled).toBe(true) + }) + + it('keeps the modal open when the save fails and discards the draft on reopen', async () => { + mutateAsync.mockRejectedValueOnce(new Error('Admin access required to change table locks')) + render() + selectPermission('Updating Rows', 'Deny') + await act(async () => { + getSave().click() + }) + expect(onClose).not.toHaveBeenCalled() + + render(UNLOCKED_TABLE_LOCKS, false) + render() + expect(getPermission('Updating Rows', 'Allow').getAttribute('aria-checked')).toBe('true') + expect(getSave().disabled).toBe(true) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx index b30531933ca..e72cdce34ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.tsx @@ -1,30 +1,32 @@ 'use client' -import { useId, useState } from 'react' +import { useState } from 'react' import { + ChipButtonGroup, + ChipButtonGroupItem, ChipModal, ChipModalBody, + ChipModalField, ChipModalFooter, ChipModalHeader, - Label, - Switch, Tooltip, } from '@sim/emcn' import { CircleInfo, Lock } from '@sim/emcn/icons' -import type { TableLocks } from '@/lib/table' -import { - describeLocks, - LOCK_FIELDS, -} from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' +import type { TableLocks } from '@/lib/table/types' +import { LOCK_FIELDS } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' import { useUpdateTableLocks } from '@/hooks/queries/tables' -function locksEqual(a: TableLocks, b: TableLocks): boolean { - return ( - a.schemaLocked === b.schemaLocked && - a.insertLocked === b.insertLocked && - a.updateLocked === b.updateLocked && - a.deleteLocked === b.deleteLocked - ) +/** + * The rows the admin actually moved, relative to the locks the server holds + * right now. Everything absent from this patch is left alone by the save. + */ +function changedLocks(overrides: Partial, locks: TableLocks): Partial { + const changed: Partial = {} + for (const field of LOCK_FIELDS) { + const next = overrides[field.key] + if (next !== undefined && next !== locks[field.key]) changed[field.key] = next + } + return changed } interface LockSettingsModalProps { @@ -36,10 +38,19 @@ interface LockSettingsModalProps { } /** - * Admin-only panel to toggle a table's four mutation locks. Changes are staged - * locally and applied on Save (one request); the server re-checks admin and - * rejects a `write`-only caller with a 403 surfaced as a toast. Gated at the - * call site on `canAdmin`. + * Admin-only panel that sets a table's four mutation locks, one Allow/Deny row + * each. The rows mirror the server flags exactly — `Deny` is a set lock — so a + * table nobody has configured opens on four `Allow`s and every viewer sees the + * same state. + * + * Only the rows this admin moved are staged; every other row keeps rendering + * the authoritative value, so a lock another admin changes while this modal is + * open shows up here instead of going stale behind it. Save sends just that + * patch (the route takes a partial), so it can't carry a stale flag over + * someone else's newer change — a row both admins moved is the only real + * conflict, and there this admin's explicit choice wins. The server re-checks + * admin and rejects a `write`-only caller with a 403 surfaced as a toast. + * Gated at the call site on `canAdmin`. */ export function LockSettingsModal({ isOpen, @@ -48,65 +59,77 @@ export function LockSettingsModal({ tableId, locks, }: LockSettingsModalProps) { - const idPrefix = useId() const updateLocks = useUpdateTableLocks(workspaceId) - // Stage edits locally; reset to the server value each time the modal opens. - const [draft, setDraft] = useState(locks) + // Stage only the rows this admin moved; clear them each time the modal opens. + const [overrides, setOverrides] = useState>({}) const [prevOpen, setPrevOpen] = useState(isOpen) if (prevOpen !== isOpen) { setPrevOpen(isOpen) - if (isOpen) setDraft(locks) + if (isOpen) setOverrides({}) } - const dirty = !locksEqual(draft, locks) - const summary = describeLocks(draft) + const changed = changedLocks(overrides, locks) + const dirty = Object.keys(changed).length > 0 - const handleSave = () => { + const handleSave = async () => { if (!dirty) { onClose() return } - updateLocks.mutate({ tableId, locks: draft }, { onSuccess: () => onClose() }) + try { + await updateLocks.mutateAsync({ tableId, locks: changed }) + } catch { + return + } + onClose() } return ( - !open && onClose()} srTitle='Table locks'> + !open && onClose()} srTitle='Table Security'> - Table locks + Table Security -

- {summary.name} — {summary.detail} -

- {LOCK_FIELDS.map((field) => { - const fieldId = `${idPrefix}-${field.kind}` - return ( -
-
- + {LOCK_FIELDS.map((field) => ( + + {field.label} - {/* Not `asChild`: the hint is each lock's only explanation, so + {/* Not `asChild`: the hint is each row's only explanation, so the trigger must be a focusable button for keyboard users. */} - +

{field.hint}

-
- - setDraft((prev) => ({ ...prev, [field.key]: checked })) - } - /> -
- ) - })} + + } + > + + setOverrides((prev) => ({ ...prev, [field.key]: value === 'deny' })) + } + > + Deny + Allow + + + ))}
({ - mockToastError: vi.fn(), - mockUseTimezoneState: vi.fn(), - mockUpdateRow: vi.fn(), - mockDeleteRow: vi.fn(), - mockDeleteRows: vi.fn(), - })) +const { + mockToastError, + mockUseTimezoneState, + mockCreateRow, + mockUpdateRow, + mockDeleteRow, + mockDeleteRows, +} = vi.hoisted(() => ({ + mockToastError: vi.fn(), + mockUseTimezoneState: vi.fn(), + mockCreateRow: vi.fn(), + mockUpdateRow: vi.fn(), + mockDeleteRow: vi.fn(), + mockDeleteRows: vi.fn(), +})) vi.mock('next/navigation', () => ({ useParams: () => ({ workspaceId: 'workspace-1' }), @@ -23,6 +30,7 @@ vi.mock('@/hooks/queries/general-settings', () => ({ useTimezoneState: mockUseTimezoneState, })) vi.mock('@/hooks/queries/tables', () => ({ + useCreateTableRow: () => ({ mutateAsync: mockCreateRow, isPending: false }), useUpdateTableRow: () => ({ mutateAsync: mockUpdateRow, isPending: false }), useDeleteTableRow: () => ({ mutateAsync: mockDeleteRow, isPending: false }), useDeleteTableRows: () => ({ mutateAsync: mockDeleteRows, isPending: false }), @@ -35,11 +43,12 @@ vi.mock('@sim/emcn', () => { createElement('button', { type: 'button', ...props }, children), ChipConfirmModal: passthrough, ChipDatePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => - createElement( - 'button', - { type: 'button', 'data-testid': 'date', onClick: () => onChange(value ?? '2026-11-01') }, - value - ), + createElement('input', { + 'data-testid': 'date', + value: value ?? '', + onChange: (event: { currentTarget: { value: string } }) => + onChange(event.currentTarget.value), + }), ChipModal: passthrough, ChipModalBody: passthrough, ChipModalError: passthrough, @@ -80,13 +89,6 @@ vi.mock('@sim/emcn', () => { 'Update Row' ), ChipModalHeader: passthrough, - ChipTimePicker: ({ value, onChange }: { value?: string; onChange: (value: string) => void }) => - createElement('input', { - 'data-testid': 'time', - value: value ?? '', - onChange: (event: { currentTarget: { value: string } }) => - onChange(event.currentTarget.value), - }), Label: passthrough, toast: { error: mockToastError }, } @@ -113,6 +115,154 @@ function changeInput(input: HTMLInputElement, value: string) { input.dispatchEvent(new Event('input', { bubbles: true })) } +describe('RowModal add mode', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateRow.mockResolvedValue(undefined) + mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', status: 'ready' }) + }) + + it('inserts the complete row under column ids in one request without updating', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'add' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-3', + name: 'People', + schema: { columns: [{ id: 'col_name', name: 'Name', type: 'string' as const }] }, + }, + onSuccess: vi.fn(), + } + + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + act(() => root.render(createElement(RowModal, props))) + + const nameInput = container.querySelector('[data-testid="modal-input"]') + expect(nameInput?.value).toBe('') + act(() => changeInput(nameInput as HTMLInputElement, 'Ada')) + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockCreateRow).toHaveBeenCalledWith({ data: { col_name: 'Ada' } }) + expect(mockUpdateRow).not.toHaveBeenCalled() + expect(props.onSuccess).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + container.remove() + }) + + it('inserts the row at the requested position', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'add' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-3', + name: 'People', + schema: { + columns: [{ id: 'col_name', name: 'Name', type: 'string' as const, required: true }], + }, + }, + insertAt: { afterRowId: 'row-1' }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + + const nameInput = container.querySelector('[data-testid="modal-input"]') + act(() => changeInput(nameInput as HTMLInputElement, 'Ada')) + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockCreateRow).toHaveBeenCalledWith({ data: { col_name: 'Ada' }, afterRowId: 'row-1' }) + act(() => root.unmount()) + container.remove() + }) + + it('keeps Add Row disabled until every required field has a value', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'add' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-3', + name: 'People', + schema: { + columns: [ + { id: 'col_name', name: 'Name', type: 'string' as const, required: true }, + { id: 'col_notes', name: 'Notes', type: 'string' as const }, + { id: 'col_active', name: 'Active', type: 'boolean' as const, required: true }, + ], + }, + }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + + const submit = () => container.querySelector('[data-testid="submit"]') + const nameInput = container.querySelectorAll('[data-testid="modal-input"]')[0] + expect(submit()?.disabled).toBe(true) + + act(() => changeInput(nameInput, 'Ada')) + expect(submit()?.disabled).toBe(false) + + act(() => changeInput(nameInput, '')) + expect(submit()?.disabled).toBe(true) + + act(() => root.unmount()) + container.remove() + }) +}) + +describe('RowModal column ids', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUpdateRow.mockResolvedValue(undefined) + mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', status: 'ready' }) + }) + + it('shows and saves edit values stored under the column id', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-4', + name: 'People', + schema: { columns: [{ id: 'col_name', name: 'Name', type: 'string' as const }] }, + }, + row: { ...row, data: { col_name: 'Ada' } }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + + const nameInput = container.querySelector('[data-testid="modal-input"]') + expect(nameInput?.value).toBe('Ada') + act(() => changeInput(nameInput as HTMLInputElement, 'Grace')) + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockUpdateRow).toHaveBeenCalledWith({ rowId: 'row-1', data: { col_name: 'Grace' } }) + act(() => root.unmount()) + container.remove() + }) +}) + describe('RowModal expiration editing', () => { beforeEach(() => { vi.clearAllMocks() @@ -136,7 +286,9 @@ describe('RowModal expiration editing', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true act(() => root.render(createElement(RowModal, props))) - expect(container.querySelector('[data-testid="time"]')?.value).toBe('01:00') + expect(container.querySelector('[data-testid="date"]')?.value).toBe( + '2026-11-01T01:00:00' + ) expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( false ) @@ -153,9 +305,9 @@ describe('RowModal expiration editing', () => { }) act(() => root.render(createElement(RowModal, props))) - const timeInput = container.querySelector('[data-testid="time"]') - expect(timeInput?.value).toBe('01:00') - act(() => changeInput(timeInput as HTMLInputElement, '01:30')) + const dateInput = container.querySelector('[data-testid="date"]') + expect(dateInput?.value).toBe('2026-11-01T01:00:00') + act(() => changeInput(dateInput as HTMLInputElement, '2026-11-01T01:30')) const submit = container.querySelector('[data-testid="submit"]') await act(async () => submit?.click()) @@ -193,7 +345,7 @@ describe('RowModal expiration editing', () => { expect(container.querySelector('[aria-label="Edit starts_at"]')?.textContent).toBe( 'Loading timezone…' ) - expect(container.querySelector('[data-testid="time"]')).toBeNull() + expect(container.querySelector('[data-testid="date"]')).toBeNull() mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', @@ -201,7 +353,7 @@ describe('RowModal expiration editing', () => { }) act(() => root.render(createElement(RowModal, props))) - expect(container.querySelector('[data-testid="time"]')).not.toBeNull() + expect(container.querySelector('[data-testid="date"]')).not.toBeNull() act(() => root.unmount()) container.remove() }) @@ -226,7 +378,9 @@ describe('RowModal expiration editing', () => { act(() => root.render(createElement(RowModal, props))) - expect(container.querySelector('[data-testid="time"]')?.value).toBe('01:00') + expect(container.querySelector('[data-testid="date"]')?.value).toBe( + '2026-11-01T01:00:00' + ) expect(container.querySelector('[data-testid="submit"]')?.disabled).toBe( false ) @@ -235,7 +389,7 @@ describe('RowModal expiration editing', () => { container.remove() }) - it('keeps unrelated fields editable and omits blocked date values from the update', async () => { + it('sends only the edited field and omits blocked date values from the update', async () => { mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', savedTimezone: 'Mars/Olympus', @@ -283,10 +437,10 @@ describe('RowModal expiration editing', () => { act(() => changeInput(nameInput as HTMLInputElement, 'Grace')) await act(async () => submit?.click()) - expect(mockUpdateRow).toHaveBeenCalledWith({ - rowId: 'row-1', - data: { name: 'Grace', expires_at: row.data.expires_at }, - }) + // Only the edited field is sent: the untouched TTL would otherwise be + // rewritten with the same value (and re-stamped through the picker), and the + // timezone-blocked date is dropped entirely. + expect(mockUpdateRow).toHaveBeenCalledWith({ rowId: 'row-1', data: { name: 'Grace' } }) expect(props.onSuccess).toHaveBeenCalledTimes(1) expect(mockToastError).not.toHaveBeenCalled() @@ -294,3 +448,76 @@ describe('RowModal expiration editing', () => { container.remove() }) }) + +describe('RowModal payload', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCreateRow.mockResolvedValue(undefined) + mockUpdateRow.mockResolvedValue(undefined) + mockUseTimezoneState.mockReturnValue({ timezone: 'America/Los_Angeles', status: 'ready' }) + }) + + it('closes without a write when the edit changes nothing', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'edit' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-5', + name: 'People', + schema: { columns: [{ id: 'col_name', name: 'Name', type: 'string' as const }] }, + }, + row: { ...row, data: { col_name: 'Ada' } }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + expect(mockUpdateRow).not.toHaveBeenCalled() + expect(props.onSuccess).toHaveBeenCalledTimes(1) + + act(() => root.unmount()) + container.remove() + }) + + it('omits untouched columns on insert but still sends toggles', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const props = { + mode: 'add' as const, + isOpen: true, + onClose: vi.fn(), + table: { + id: 'table-6', + name: 'People', + schema: { + columns: [ + { id: 'col_name', name: 'Name', type: 'string' as const }, + { id: 'col_notes', name: 'Notes', type: 'string' as const }, + { id: 'col_done', name: 'Done', type: 'boolean' as const }, + ], + }, + }, + onSuccess: vi.fn(), + } + + act(() => root.render(createElement(RowModal, props))) + const nameInput = container.querySelector('[data-testid="modal-input"]') + act(() => changeInput(nameInput as HTMLInputElement, 'Ada')) + const submit = container.querySelector('[data-testid="submit"]') + await act(async () => submit?.click()) + + // `col_notes` was never touched, so it stays absent instead of being written + // as null; a checkbox always carries a concrete boolean. + expect(mockCreateRow).toHaveBeenCalledWith({ data: { col_name: 'Ada', col_done: false } }) + + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx index 94939e31e28..9da75292fae 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.tsx @@ -12,7 +12,6 @@ import { ChipModalField, ChipModalFooter, ChipModalHeader, - ChipTimePicker, Label, toast, } from '@sim/emcn' @@ -20,17 +19,26 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table' +import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { resolveCurrencyCode } from '@/lib/table/currency' +import { isEmptyCellValue } from '@/lib/table/deps' import { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values' import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing' +import type { RowInsertTarget } from '@/app/workspace/[workspaceId]/tables/[tableId]/types' import { type TimezoneState, useTimezoneState } from '@/hooks/queries/general-settings' -import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables' +import { + useCreateTableRow, + useDeleteTableRow, + useDeleteTableRows, + useUpdateTableRow, +} from '@/hooks/queries/tables' import { cleanCellValue, dateValueToLocalParts, formatValueForInput, localPartsToDateValue, + storageToDisplay, todayLocalCalendarDate, } from '../../utils' import { SelectValueEditor } from '../select-field' @@ -38,47 +46,89 @@ import { SelectValueEditor } from '../select-field' const logger = createLogger('RowModal') export interface RowModalProps { - mode: 'edit' | 'delete' + mode: 'add' | 'edit' | 'delete' isOpen: boolean onClose: () => void table: TableInfo row?: TableRow rowIds?: string[] + /** Where add mode inserts the row; appends when omitted. */ + insertAt?: RowInsertTarget onSuccess: () => void } +/** Structural equality for a cleaned cell value vs what the row already holds. */ +function cellValueUnchanged(next: unknown, previous: unknown): boolean { + if (next === previous) return true + const nextEmpty = next === null || next === undefined + const previousEmpty = previous === null || previous === undefined + if (nextEmpty || previousEmpty) return nextEmpty && previousEmpty + if (typeof next === 'object' || typeof previous === 'object') { + return JSON.stringify(next) === JSON.stringify(previous) + } + return false +} + +/** + * Builds the write payload. Only fields the user actually touched are sent, so + * an untouched empty column is left absent instead of being written as `null` — + * and in edit mode a field whose value is unchanged is dropped too, leaving a + * no-op save with nothing to write. Toggles are the exception on insert: they + * always carry a concrete boolean, so a required checkbox the user never + * clicked still has to reach the server as `false`. + */ function cleanRowData( columns: ColumnDefinition[], rowData: Record, timeZone: string, - dateEditorsReady: boolean + dateEditorsReady: boolean, + options: { mode: 'add' | 'edit'; baseline?: Record } ): Record { const cleanData: Record = {} columns.forEach((col) => { - const value = rowData[col.name] - if (columnTypeOf(col).editor === 'date' && !dateEditorsReady) { + const columnId = getColumnId(col) + const definition = columnTypeOf(col) + if (definition.editor === 'date' && !dateEditorsReady) { return } + const touched = columnId in rowData + const alwaysSend = options.mode === 'add' && definition.editor === 'toggle' + if (!touched && !alwaysSend) return + const value = rowData[columnId] + let cleaned: unknown try { - cleanData[col.name] = cleanCellValue(value, col, timeZone) + cleaned = cleanCellValue(value, col, timeZone) } catch { throw new Error(`Invalid JSON for field: ${col.name}`) } + if (options.baseline && cellValueUnchanged(cleaned, options.baseline[columnId])) return + cleanData[columnId] = cleaned }) return cleanData } /** - * Modal for editing a row's values or confirming row deletion. + * Modal for adding a complete row, editing a row's values, or confirming row + * deletion. Adding inserts every value in one request, so it works on a table + * whose update lock blocks filling in a blank row from the grid. * - * `rowData` is initialized from the `row` prop at mount time only. Both call-sites - * conditionally mount this component per open, so each open gets fresh state. If a + * `rowData` is initialized from the `row` prop at mount time only. Every call-site + * conditionally mounts this component per open, so each open gets fresh state. If a * call-site ever keeps it mounted across target-row changes, it must supply a `key` * prop (e.g. the row id) so React remounts with the new row's values. */ -export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) { +export function RowModal({ + mode, + isOpen, + onClose, + table, + row, + rowIds, + insertAt, + onSuccess, +}: RowModalProps) { const params = useParams() const workspaceId = params.workspaceId as string const tableId = table.id @@ -97,33 +147,58 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess mode === 'edit' && row ? row.data : {} ) const [error, setError] = useState(null) - const updateRowMutation = useUpdateTableRow({ workspaceId, tableId }) - const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId }) - const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId }) + // This modal renders its own failure in ``; without the flag + // every rejection would also arrive as a toast saying the same sentence. + const rowMutationContext = { workspaceId, tableId, suppressErrorToast: true } + const createRowMutation = useCreateTableRow(rowMutationContext) + const updateRowMutation = useUpdateTableRow(rowMutationContext) + const deleteRowMutation = useDeleteTableRow(rowMutationContext) + const deleteRowsMutation = useDeleteTableRows(rowMutationContext) const isSubmitting = - updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending + createRowMutation.isPending || + updateRowMutation.isPending || + deleteRowMutation.isPending || + deleteRowsMutation.isPending + const isAddMode = mode === 'add' const timezoneBlockedMessage = getTimezoneEditBlockedMessage(timezoneState) const hasEditableColumn = columns.some( (column) => columnTypeOf(column).editor !== 'date' || dateEditorsReady ) + /** Toggles always save a boolean, so only other required columns can be left empty. */ + const missingRequiredValue = columns.some( + (column) => + column.required && + columnTypeOf(column).editor !== 'toggle' && + isEmptyCellValue(rowData[getColumnId(column)]) + ) + const canSubmit = hasEditableColumn && !missingRequiredValue const handleFormSubmit = async (e?: React.FormEvent) => { e?.preventDefault() setError(null) - if (!hasEditableColumn) return + if (!canSubmit) return try { - const cleanData = cleanRowData(columns, rowData, timeZone, dateEditorsReady) - - if (row) { - await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData }) + const cleanData = cleanRowData(columns, rowData, timeZone, dateEditorsReady, { + mode: isAddMode ? 'add' : 'edit', + baseline: isAddMode ? undefined : row?.data, + }) + + if (isAddMode) { + await createRowMutation.mutateAsync({ data: cleanData, ...insertAt }) + } else if (row) { + // Nothing changed — close instead of writing an empty patch. + if (Object.keys(cleanData).length > 0) { + await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData }) + } } onSuccess() } catch (err) { - logger.error('Failed to edit row:', err) - setError(getErrorMessage(err, 'Failed to edit row')) + const action = isAddMode ? 'add' : 'edit' + logger.error(`Failed to ${action} row:`, err) + setError(getErrorMessage(err, `Failed to ${action} row`)) } } @@ -182,20 +257,25 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess } return ( - - Edit Row + + {isAddMode ? 'Add Row' : 'Edit Row'}

- Update values for {table?.name ?? 'table'} + {isAddMode ? 'Fill in values for' : 'Update values for'} {table?.name ?? 'table'}

- - + {saveBlockedReason ? ( + + + + + + + {saveBlockedReason} + + ) : ( + + )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts index d08d7cb5a9e..66900798c6d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.test.ts @@ -396,3 +396,66 @@ describe('dateEditorRawValue', () => { container.remove() }) }) + +describe('read-only InlineEditor', () => { + beforeEach(() => { + vi.clearAllMocks() + mockUseTimezoneState.mockReturnValue({ + timezone: 'America/Los_Angeles', + status: 'ready', + }) + }) + + it('shows a text value that can be selected but not changed', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onSave = vi.fn() + + act(() => + root.render( + createElement(InlineEditor, { + value: 'Original text', + column: column('string'), + readOnly: true, + onSave, + onCancel: vi.fn(), + }) + ) + ) + + const input = container.querySelector('input') as HTMLInputElement + expect(input.value).toBe('Original text') + expect(input.readOnly).toBe(true) + act(() => input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))) + + expect(onSave).toHaveBeenCalledWith('Original text', 'enter') + act(() => root.unmount()) + container.remove() + }) + + it('opens a date read-only without the calendar picker', () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => + root.render( + createElement(InlineEditor, { + value: '2026-06-15T06:00:30-07:00', + column: column('ttl'), + readOnly: true, + onSave: vi.fn(), + onCancel: vi.fn(), + }) + ) + ) + + const input = container.querySelector('input') as HTMLInputElement + expect(input.value).toBe('2026-06-15T06:00:30-07:00') + expect(input.readOnly).toBe(true) + expect(mockCalendar).not.toHaveBeenCalled() + act(() => root.unmount()) + container.remove() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx index d6e754beb4a..dff9efd7ba3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx @@ -35,6 +35,8 @@ interface InlineEditorProps { value: unknown column: ColumnDefinition initialCharacter?: string + /** Shows the value without allowing changes; text stays selectable and copyable. */ + readOnly?: boolean onSave: (value: unknown, reason: SaveReason) => void onCancel: () => void } @@ -105,6 +107,7 @@ function ReadyInlineDateEditor({ value, column, initialCharacter, + readOnly, onSave, onCancel, initialTimeZone, @@ -274,33 +277,38 @@ function ReadyInlineDateEditor({ }} onKeyDown={handleKeyDown} onBlur={scheduleBlurSave} + readOnly={readOnly} placeholder={isOffsetDate ? 'YYYY-MM-DDTHH:mm:ss±HH:mm' : 'mm/dd/yyyy'} className={cn( 'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-hidden', invalid && 'text-[var(--text-error)]' )} /> - - - - - - + {!readOnly && ( + + + + + + + )} ) } @@ -310,6 +318,7 @@ function InlineTextEditor({ value, column, initialCharacter, + readOnly, onSave, onCancel, }: InlineEditorProps) { @@ -394,6 +403,7 @@ function InlineTextEditor({ onKeyDown={handleKeyDown} onWheel={handleEditorWheel} onBlur={() => doSave('blur')} + readOnly={readOnly} className={cn( 'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-hidden', invalid && 'text-[var(--text-error)]' @@ -409,7 +419,7 @@ function InlineTextEditor({ * toggles and commits when the menu closes. Escape discards the draft, matching * the text/date inline editors. */ -function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorProps) { +function InlineSelectEditor({ value, column, readOnly, onSave, onCancel }: InlineEditorProps) { const isMulti = !!column.multiple const allOptions = column.options ?? [] const [draft, setDraft] = useState(() => selectedOptionIds(column, value)) @@ -475,13 +485,17 @@ function InlineSelectEditor({ value, column, onSave, onCancel }: InlineEditorPro {!isMulti && !column.required && ( - setDraftAnd([])}> + setDraftAnd([])}> None {draft.length === 0 && } )} {allOptions.map((option) => ( - handleSelectOption(e, option.id)}> + handleSelectOption(e, option.id)} + > {draft.includes(option.id) && } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx index 86963aa768b..0c603a6e1dc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx @@ -33,6 +33,8 @@ export interface DataRowProps { isFirstRow: boolean editingColumnName: string | null initialCharacter: string | null + /** Opens cell editors read-only, e.g. on an update-locked table. */ + editorsReadOnly: boolean pendingCellValue: Record | null normalizedSelection: NormalizedSelection | null onClick: (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => void @@ -121,6 +123,7 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.rowIndex !== next.rowIndex || prev.isFirstRow !== next.isFirstRow || prev.editingColumnName !== next.editingColumnName || + prev.editorsReadOnly !== next.editorsReadOnly || prev.pendingCellValue !== next.pendingCellValue || prev.onClick !== next.onClick || prev.onDoubleClick !== next.onDoubleClick || @@ -170,6 +173,7 @@ export const DataRow = React.memo(function DataRow({ isFirstRow, editingColumnName, initialCharacter, + editorsReadOnly, pendingCellValue, normalizedSelection, isRowChecked, @@ -417,6 +421,7 @@ export const DataRow = React.memo(function DataRow({ column={column} isEditing={isEditing} initialCharacter={isEditing ? initialCharacter : undefined} + readOnly={editorsReadOnly} onSave={(value, reason) => onSave(row.id, column.key, value, reason)} onCancel={onCancel} waitingOnLabels={ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index e2667e8434b..6ba6ebfa820 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -15,6 +15,10 @@ interface ColumnHeaderMenuProps { column: DisplayColumn colIndex: number readOnly?: boolean + /** Why column changes are unavailable; disables the schema rows and explains them. */ + schemaLockedReason?: string + /** Why deleting is unavailable; disables the destructive column row. */ + deleteLockedReason?: string isRenaming: boolean isColumnSelected: boolean renameValue: string @@ -65,6 +69,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, colIndex, readOnly, + schemaLockedReason, + deleteLockedReason, isRenaming, isColumnSelected, renameValue, @@ -346,6 +352,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column={column} deleteLabel={deleteLabel} onOpenConfig={onOpenConfig} + schemaLockedReason={schemaLockedReason} + deleteLockedReason={deleteLockedReason} onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} onDeleteColumn={onDeleteColumn} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index e9f4e435e11..69c159e585b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -12,6 +12,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Tooltip, } from '@sim/emcn' import { ArrowDown, @@ -70,6 +71,10 @@ interface ColumnOptionsMenuProps { * it leaves the group with siblings). */ deleteLabel?: string onOpenConfig: (columnName: string) => void + /** Why column changes are unavailable; disables the schema rows and explains them. */ + schemaLockedReason?: string + /** Why deleting is unavailable; disables the destructive column row. */ + deleteLockedReason?: string onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void onDeleteColumn: (columnName: string) => void @@ -108,6 +113,24 @@ interface ColumnOptionsMenuProps { onPinToggle?: (columnName: string) => void } +/** + * A menu row a lock disables. A disabled `DropdownMenuItem` sets + * `pointer-events: none`, so it can never receive the hover its own tooltip + * would need — the trigger wraps it instead (same shape as the folder menu). + * Renders the row untouched when nothing blocks it. + */ +function MenuRow({ reason, children }: { reason?: string; children: React.ReactElement }) { + if (!reason) return children + return ( + + +
{children}
+
+ {reason} +
+ ) +} + /** * Shared column-options dropdown rendered next to the column header chevron * AND on right-click of the workflow group meta cell. Anchors to a fixed @@ -122,6 +145,8 @@ export function ColumnOptionsMenu({ column, deleteLabel, onOpenConfig, + schemaLockedReason, + deleteLockedReason, onInsertLeft, onInsertRight, onDeleteColumn, @@ -139,6 +164,9 @@ export function ColumnOptionsMenu({ isPinned, onPinToggle, }: ColumnOptionsMenuProps) { + // Hiding a workflow output leaves the data alone, so no lock covers it. + const destructiveReason = + deleteLabel === 'Hide column' ? undefined : (schemaLockedReason ?? deleteLockedReason) const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 const runLabels = runMenuLabels(hasActiveFilter) @@ -228,10 +256,15 @@ export function ColumnOptionsMenu({ View workflow
)} - onOpenConfig(column.key)}> - - Edit column - + + onOpenConfig(column.key)} + > + + Edit column + + {onPinToggle && ( onPinToggle(column.key)}> {isPinned ? : } @@ -239,23 +272,36 @@ export function ColumnOptionsMenu({ )} {/* Stops acting on this column and starts creating siblings — `Edit column` - above is unconditional, so the rule is always backed. */} + above always renders (disabled or not), so the rule is always backed. */} - onInsertLeft(column.key)}> - - Insert column left - - onInsertRight(column.key)}> - - Insert column right - + + onInsertLeft(column.key)} + > + + Insert column left + + + + onInsertRight(column.key)} + > + + Insert column right + + - (onDeleteGroup ? onDeleteGroup() : onDeleteColumn(column.key))} - > - {deleteLabel === 'Hide column' ? : } - {deleteLabel ?? 'Delete column'} - + + (onDeleteGroup ? onDeleteGroup() : onDeleteColumn(column.key))} + > + {deleteLabel === 'Hide column' ? : } + {deleteLabel ?? 'Delete column'} + +
) @@ -281,6 +327,10 @@ interface WorkflowGroupMetaCellProps { isGroupSelected: boolean onSelectGroup: (startColIndex: number, size: number) => void onOpenConfig: (columnName: string) => void + /** Why column changes are unavailable; disables the schema rows and explains them. */ + schemaLockedReason?: string + /** Why deleting is unavailable; disables the destructive column row. */ + deleteLockedReason?: string onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[], limit?: RunLimit) => void onInsertLeft?: (columnName: string) => void onInsertRight?: (columnName: string) => void @@ -334,6 +384,8 @@ export function WorkflowGroupMetaCell({ isGroupSelected, onSelectGroup, onOpenConfig, + schemaLockedReason, + deleteLockedReason, onRunColumn, onInsertLeft, onInsertRight, @@ -539,6 +591,8 @@ export function WorkflowGroupMetaCell({ position={optionsMenuPosition} column={column} onOpenConfig={onOpenConfig} + schemaLockedReason={schemaLockedReason} + deleteLockedReason={deleteLockedReason} onInsertLeft={onInsertLeft} onInsertRight={onInsertRight} onDeleteColumn={onDeleteColumn} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index f800493d046..f040e922466 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -27,6 +27,7 @@ import type { import { getColumnId } from '@/lib/table/column-keys' import { columnTypeOf } from '@/lib/table/column-types' import { TABLE_LIMITS } from '@/lib/table/constants' +import { isEmptyCellValue } from '@/lib/table/deps' import { cellValueFilterConditions } from '@/lib/table/query-builder/cell-filter' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { FindBar } from '@/app/workspace/[workspaceId]/components' @@ -34,6 +35,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing' import type { RemoteTableSelection } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-room' import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' +import { LOCK_TOOLTIPS } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy' import { useTimezoneState } from '@/hooks/queries/general-settings' import { useAddTableColumn, @@ -55,7 +57,7 @@ import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' import type { ChatContext } from '@/stores/panel' import type { DeletedRowSnapshot } from '@/stores/table/types' import { useContextMenu, useTable } from '../../hooks' -import type { EditingCell, QueryOptions, SaveReason } from '../../types' +import type { EditingCell, QueryOptions, RowInsertTarget, SaveReason } from '../../types' import { cleanCellValue, generateColumnName as sharedGenerateColumnName } from '../../utils' import type { ColumnConfig } from '../column-config-sidebar' import { ColumnDropdown } from '../column-dropdown' @@ -208,6 +210,8 @@ interface TableGridProps { onOpenEnrichmentDetails: (rowId: string, groupId: string) => void /** Open the row-edit modal for `row`. Wrapper renders the modal. */ onOpenRowModal: (row: TableRowType) => void + /** Opens the add-row form, which inserts a complete row at `insertAt` (appends when omitted). */ + onOpenAddRowModal: (insertAt?: RowInsertTarget) => void /** Open the row-delete modal for `snapshots`. Wrapper renders the modal. */ onRequestDeleteRows: (snapshots: DeletedRowSnapshot[]) => void /** @@ -370,6 +374,15 @@ function writeLoadedRowsWithChip(opts: { return true } +/** + * Whether new rows must go through the add-row form instead of a blank grid row. + * A blank row only works when the grid can fill it in afterwards: typing into it + * is an update, and the server rejects an empty row when any column is required. + */ +function needsAddRowForm(updateLocked: boolean | undefined, columns: ColumnDefinition[]): boolean { + return Boolean(updateLocked) || columns.some((column) => column.required) +} + /** * Value-equality for a cell's stored value vs a pending edit. Primitives compare * with `===`; arrays/objects (multiselect id arrays, json) compare structurally @@ -450,6 +463,7 @@ export function TableGrid({ onOpenExecutionDetails, onOpenEnrichmentDetails, onOpenRowModal, + onOpenAddRowModal, onRequestDeleteRows, onRequestDeleteAllByFilter, onRequestDeleteColumns, @@ -699,15 +713,14 @@ export function TableGrid({ // requires the delete lock clear too — mirror that here or the affordance // stays live on an append-only table and only fails on click. const canDestroyColumn = canMutateSchema && !locks?.deleteLocked - // Duplicate inserts a full copied row in one shot, so unlike the blank-row - // paths it needs the insert lock only — it is valid on an append-only table. + /** + * Inserts that carry the whole row in one request (Duplicate, paste-append, the + * add-row form) need only the insert lock, so they stay valid on an append-only + * table. New row, Shift+Enter, and Insert row fall back to that form whenever + * `needsAddRowForm` says a blank row can't work. + */ const canInsertFullRow = userPermissions.canEdit && !locks?.insertLocked - // Manual grid entry is "add an empty row, then type into its cells" — the - // typing is an update. So a *useful* manual add needs BOTH insert and update - // unlocked; on an append-only table (update locked) it would leave a blank - // row the user can't fill. The control stays visible and explains itself via - // `onBlockedAction`. Full-row inserts still flow through CSV import / API / - // blocks / Mothership, which the insert lock alone governs server-side. + /** A blank grid row is filled in by typing, which is an update, so it needs both locks off. */ const canManualAddRow = userPermissions.canEdit && !locks?.insertLocked && !locks?.updateLocked const canEditCellRef = useRef(canEditCell) canEditCellRef.current = canEditCell @@ -715,8 +728,8 @@ export function TableGrid({ canManualAddRowRef.current = canManualAddRow const canInsertFullRowRef = useRef(canInsertFullRow) canInsertFullRowRef.current = canInsertFullRow - // Read by the closure-free double-click handler to tell "locked" apart from - // "no write permission" — only the former gets the explanation modal. + // Read by the closure-free save and keyboard handlers to tell "locked" apart + // from "no write permission" — only the former gets the explanation toast. const updateLockedRef = useRef(locks?.updateLocked) updateLockedRef.current = locks?.updateLocked const onBlockedActionRef = useRef(onBlockedAction) @@ -727,6 +740,8 @@ export function TableGrid({ // Refs for callback props read inside effects with stable empty deps. const onOpenRowModalRef = useRef(onOpenRowModal) onOpenRowModalRef.current = onOpenRowModal + const onOpenAddRowModalRef = useRef(onOpenAddRowModal) + onOpenAddRowModalRef.current = onOpenAddRowModal const { contextMenu, @@ -1617,6 +1632,11 @@ export function TableGrid({ const anchorId = contextMenu.row.id // Fractional ordering: express intent by neighbor id, not integer position. const intent = offset === 0 ? { beforeRowId: anchorId } : { afterRowId: anchorId } + if (needsAddRowForm(updateLockedRef.current, schemaColumnsRef.current)) { + closeContextMenu() + onOpenAddRowModalRef.current(intent) + return + } createRef.current( { data: {}, ...intent }, { @@ -1756,6 +1776,13 @@ export function TableGrid({ // Stable identity so 's React.memo still bails out; lock state // is read from refs instead of being closed over. const handleAddRowClick = useCallback(() => { + if ( + canInsertFullRowRef.current && + needsAddRowForm(updateLockedRef.current, schemaColumnsRef.current) + ) { + onOpenAddRowModalRef.current() + return + } if (!canManualAddRowRef.current) { onBlockedActionRef.current('add-row') return @@ -2721,7 +2748,16 @@ export function TableGrid({ (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => { const column = columnsRef.current.find((c) => c.key === columnName) if (column && columnTypeOf(column).editor === 'toggle') { - if (!options?.toggleBoolean || !canEditCellRef.current) return + if (!options?.toggleBoolean) return + // A toggle writes on the click itself, so there is no read-only editor to + // fall back to — an update-locked table has to explain the refusal here, + // the same way the Enter/Space keyboard paths do. + if (!canEditCellRef.current) { + if (canEditRef.current && updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + } + return + } const row = rowsRef.current.find((r) => r.id === rowId) if (row) { toggleBooleanCell(rowId, columnName, row.data[columnName]) @@ -2741,23 +2777,24 @@ export function TableGrid({ (rowId: string, columnName: string, columnKey: string) => { const column = columnsRef.current.find((c) => c.key === columnKey) if (column && columnTypeOf(column).editor === 'toggle') return - - // Double-click means "edit this cell". On an update-locked table, say so - // rather than opening the expanded viewer — which looks like an editor - // that silently refuses to save. Only for users who could otherwise edit: - // without write access the lock isn't why they can't, and they still get - // the read-only expanded viewer below. - if (canEditRef.current && updateLockedRef.current) { - onBlockedActionRef.current('edit-cell') + // A read-only view of an empty cell has nothing to show or copy. + if ( + !canEditCellRef.current && + isEmptyCellValue(rowsRef.current.find((r) => r.id === rowId)?.data[columnName]) + ) { return } setSelectionFocus(null) setIsColumnSelection(false) - // Types with a bounded value edit in place (calendar picker, numeric - // input); only free-form prose opens the big expanded popover. - if (column && !columnTypeOf(column).expandable && canEditCellRef.current) { + // Editors open for anyone with write access. On an update-locked table + // they open read-only, so the value can still be selected and copied; + // `handleInlineSave` stays as a backstop that refuses any change with the + // lock explanation. Types with a bounded value edit in place (calendar + // picker, numeric input); only free-form prose opens the big expanded + // popover. + if (column && !columnTypeOf(column).expandable && canEditRef.current) { setEditingCell({ rowId, columnName }) setInitialCharacter(null) return @@ -2966,14 +3003,22 @@ export function TableGrid({ if (e.shiftKey && e.key === 'Enter') { if (!canEditRef.current) return - // Same manual-add path as the Add row button, so it owes the same - // explanation rather than silently doing nothing on a locked table. + const row = currentRows[anchor.rowIndex] + // Mirrors handleAddRowClick; keep the two new-row paths in sync. + if ( + row && + canInsertFullRowRef.current && + needsAddRowForm(updateLockedRef.current, schemaColumnsRef.current) + ) { + e.preventDefault() + onOpenAddRowModalRef.current({ afterRowId: row.id }) + return + } if (!canManualAddRowRef.current) { e.preventDefault() onBlockedActionRef.current('add-row') return } - const row = currentRows[anchor.rowIndex] if (!row) return e.preventDefault() const position = row.position + 1 @@ -2997,23 +3042,24 @@ export function TableGrid({ if (e.key === 'Enter' || e.key === 'F2') { if (!canEditRef.current) return e.preventDefault() - // The primary keyboard edit path — same lock notice as double-click and - // Space, rather than a keypress that silently does nothing. - if (updateLockedRef.current) { - onBlockedActionRef.current('edit-cell') - return - } - if (!canEditCellRef.current) return const col = cols[anchor.colIndex] if (!col) return const row = currentRows[anchor.rowIndex] if (!row) return + // The keyboard twin of double-click: the editor opens read-only on an + // update-locked table. A toggle writes on the keypress itself, so it + // explains the lock here instead. if (columnTypeOf(col).editor === 'toggle') { + if (updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + return + } toggleBooleanCellRef.current(row.id, col.key, row.data[col.key]) return } + if (!canEditCellRef.current && isEmptyCellValue(row.data[col.key])) return setEditingCell({ rowId: row.id, columnName: col.key }) setInitialCharacter(null) return @@ -3022,8 +3068,8 @@ export function TableGrid({ if (e.key === ' ' && !e.shiftKey) { if (!canEditRef.current) return e.preventDefault() - // Space opens the same row editor as double-click, so it follows the - // update lock too — otherwise the form fills in and only 423s on save. + // Space opens the whole-row editor, which explains the update lock up + // front — otherwise the form fills in and only 423s on save. if (updateLockedRef.current) { onBlockedActionRef.current('edit-cell') return @@ -3846,6 +3892,14 @@ export function TableGrid({ } const changed = !cellValuesEqual(oldValue, normalizedValue, column) + if (changed && updateLockedRef.current) { + onBlockedActionRef.current('edit-cell') + setEditingCell(null) + setInitialCharacter(null) + scrollRef.current?.focus({ preventScroll: true }) + return + } + if (changed) { pushUndoRef.current({ type: 'update-cell', @@ -4746,6 +4800,12 @@ export function TableGrid({ groupName={workflowGroupById.get(g.groupId)?.name} onSelectGroup={handleGroupSelect} onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)} + schemaLockedReason={ + locks?.schemaLocked ? LOCK_TOOLTIPS.schema : undefined + } + deleteLockedReason={ + locks?.deleteLocked ? LOCK_TOOLTIPS.delete : undefined + } onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} hasActiveFilter={Boolean(effectiveFilter)} selectedRowIds={selectedRowIds} @@ -4887,6 +4947,12 @@ export function TableGrid({ workflowGroups={tableWorkflowGroups} sourceInfo={columnSourceInfo.get(column.key)} onOpenConfig={handleConfigureColumn} + schemaLockedReason={ + locks?.schemaLocked ? LOCK_TOOLTIPS.schema : undefined + } + deleteLockedReason={ + locks?.deleteLocked ? LOCK_TOOLTIPS.delete : undefined + } onViewWorkflow={handleViewWorkflow} onSortColumn={onSortColumn} onClearSort={onClearSort} @@ -4907,7 +4973,6 @@ export function TableGrid({ trigger='inline-header' disabled={addColumnMutation.isPending} blocked={!canMutateSchema} - onBlocked={() => onBlockedAction('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -4962,6 +5027,7 @@ export function TableGrid({ initialCharacter={ editingCell?.rowId === row.id ? initialCharacter : null } + editorsReadOnly={Boolean(locks?.updateLocked)} pendingCellValue={ pendingUpdate && pendingUpdate.rowId === row.id ? pendingUpdate.data @@ -5050,7 +5116,10 @@ export function TableGrid({ )} {!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( - + )} @@ -5092,7 +5161,7 @@ export function TableGrid({ hasWorkflowColumns={hasWorkflowColumns} workflowCellScoped={Boolean(contextMenuGroupId)} disableEdit={!canEditCell} - disableInsert={!canManualAddRow} + disableInsert={!canInsertFullRow} disableDuplicate={!canInsertFullRow} disableDelete={!canDeleteRow} onAddToChat={addToChatRowIds.length > 0 ? handleAddSelectionToChat : undefined} @@ -5108,7 +5177,8 @@ export function TableGrid({ rows={rows} columns={displayColumns} onSave={handleInlineSave} - canEdit={canEditCell} + canEdit={userPermissions.canEdit} + saveBlockedReason={locks?.updateLocked ? LOCK_TOOLTIPS.update : undefined} scrollContainer={scrollRef.current} /> diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx index f05d7332524..fab52045ad8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-primitives.tsx @@ -1,8 +1,8 @@ 'use client' import React from 'react' -import { Button, Checkbox, cn } from '@sim/emcn' -import { Plus } from '@sim/emcn/icons' +import { Button, Checkbox, cn, Tooltip } from '@sim/emcn' +import { Lock, Plus } from '@sim/emcn/icons' import { ADD_COL_WIDTH, CELL_HEADER_CHECKBOX, COL_WIDTH } from './constants' import type { DisplayColumn } from './types' @@ -58,19 +58,42 @@ export const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ ) }) -export const AddRowButton = React.memo(function AddRowButton({ onClick }: { onClick: () => void }) { +interface AddRowButtonProps { + onClick: () => void + blockedReason?: string +} + +export const AddRowButton = React.memo(function AddRowButton({ + onClick, + blockedReason, +}: AddRowButtonProps) { + const Icon = blockedReason ? Lock : Plus + const button = ( + + ) return (
- + {blockedReason ? ( + + {button} + {blockedReason} + + ) : ( + button + )}
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts index 6f559998503..8ff9790506d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -1,18 +1,22 @@ /** - * Single source of truth for lock vocabulary shared by the lock settings modal + * Single source of truth for lock vocabulary shared by the Table Security modal * and the lock toasts (the on-open announcement and blocked actions). Kept out of * `lib/table/mutation-locks.ts` — that module is server-tainted (importing it * from a client component pulls `next/headers` into the browser bundle). + * + * The modal speaks Allow/Deny, so this copy does too: a set lock reads as its + * action being "disabled", never as a separate "locked" state. */ import type { TableLockKind, TableLocks } from '@/lib/table/types' export interface LockField { - /** The `TableLocks` flag this row toggles. */ + /** The `TableLocks` flag this row controls. */ key: keyof TableLocks kind: TableLockKind - /** The action being locked, phrased to read after "Lock " and inside a list. */ + /** The action being denied, phrased to read inside a list. */ noun: string + label: string hint: string } @@ -20,71 +24,61 @@ export const LOCK_FIELDS: LockField[] = [ { key: 'insertLocked', kind: 'insert', - noun: 'adding rows', - hint: 'On: no new rows can be added — by anyone, including CSV import, the API, workflow blocks, and Sim.', + noun: 'inserting rows', + label: 'Inserting Rows', + hint: 'Allow new rows to be added, including through CSV imports, the API, workflows, and Sim. Deny blocks new rows from every surface.', }, { key: 'updateLocked', kind: 'update', - noun: 'editing rows', - hint: 'On: existing cell values cannot be changed. Workflow and enrichment columns still populate.', + noun: 'updating rows', + label: 'Updating Rows', + hint: 'Allow existing cell values to be changed. Deny blocks edits from every surface. Workflow and enrichment columns still populate.', }, { key: 'deleteLocked', kind: 'delete', noun: 'deleting rows', - hint: 'On: rows cannot be deleted, and the table cannot be archived.', + label: 'Deleting Rows', + hint: 'Allow rows to be deleted and the table to be archived. Deny blocks those actions and destructive column changes.', }, { key: 'schemaLocked', kind: 'schema', - noun: 'changing columns', - hint: 'On: columns cannot be added, renamed, retyped, or removed.', + noun: 'changing the table schema', + label: 'Changing Table Schema', + hint: 'Allow columns to be added, renamed, retyped, or removed. Deny blocks schema changes. Removing or retyping columns also requires Deleting Rows set to Allow.', }, ] -/** The locked verbs' nouns, in display order. Empty when nothing is locked. */ -export function lockedNouns(locks: TableLocks): string[] { - return LOCK_FIELDS.filter((f) => locks[f.key]).map((f) => f.noun) -} - /** - * Plain-language summary of a lock set — the named mode when the combination - * matches one, otherwise a list of what is locked. + * Tooltip for a control a denied action disables. One sentence per lock kind so + * the grid chrome (New row, New column, the column menu, the expanded editor's + * Save) all name the same Table Security row. */ -export function describeLocks(locks: TableLocks): { name: string; detail: string } { - const locked = lockedNouns(locks) - if (locked.length === 0) { - return { name: 'Unlocked', detail: 'anyone with edit access can change this table.' } - } - if (locked.length === LOCK_FIELDS.length) { - return { name: 'Read-only', detail: 'no one can change this table’s rows or columns.' } - } - // Append-only describes the row semantics — adding is the only thing left. - // A schema lock on top doesn't change that, so it keeps the name and is - // called out in the detail rather than demoted to the generic case. - if (!locks.insertLocked && locks.updateLocked && locks.deleteLocked) { - return { - name: 'Append-only', - detail: locks.schemaLocked - ? 'rows can be added, but not edited or deleted, and columns are locked.' - : 'rows can be added, but not edited or deleted.', - } - } - return { name: 'Locked', detail: `${locked.join(', ')} locked.` } +export const LOCK_TOOLTIPS: Record = { + insert: 'Inserting rows is disabled in Table Security.', + update: 'Updating rows is disabled in Table Security.', + delete: 'Deleting rows is disabled in Table Security.', + schema: 'Changing the table schema is disabled in Table Security.', +} + +/** The denied actions' nouns, in display order. Empty when everything is allowed. */ +export function lockedNouns(locks: TableLocks): string[] { + return LOCK_FIELDS.filter((f) => locks[f.key]).map((f) => f.noun) } /** * Why a locked-table notice was raised. `'status'` is the informational case - * (the announcement shown once when a locked table is opened); the rest are + * (the announcement shown once when a restricted table is opened); the rest are * actions the user just tried and couldn't do. */ export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status' /** - * Copy for the action the user attempted. Explains what is blocked and — for - * the append-only manual-entry case — what to do instead, since that one is - * blocked by the *update* lock rather than the insert lock. + * Copy for the action the user attempted, in the modal's vocabulary: each + * notice names the Table Security row that denies it, so the reader knows which + * setting an admin has to flip. */ export function describeBlockedAction( action: BlockedTableAction, @@ -92,46 +86,40 @@ export function describeBlockedAction( ): { title: string; text: string } { switch (action) { case 'add-row': - if (locks.insertLocked) { - return { - title: 'Adding rows is locked', - text: 'No new rows can be added until an admin unlocks this table.', - } - } return { - title: 'This table is append-only', - text: 'Rows can’t be edited once added, so typing one into the grid is unavailable. Import a CSV, or add rows from the API, a workflow, or Sim.', + title: 'Inserting rows is disabled', + text: 'An admin has set Inserting Rows to Deny in Table Security.', } case 'add-column': return { - title: 'Changing columns is locked', - text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + title: 'Changing the table schema is disabled', + text: 'An admin has set Changing Table Schema to Deny in Table Security, so columns can’t be added, renamed, retyped, or removed.', } case 'delete-column': - // Reachable with the schema lock off but the delete lock on — removing a - // column clears its value from every row, so it needs both. + // Reachable with Changing Table Schema on Allow but Deleting Rows on Deny — + // removing a column clears its value from every row, so it needs both. return locks.schemaLocked ? { - title: 'Changing columns is locked', - text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.', + title: 'Changing the table schema is disabled', + text: 'An admin has set Changing Table Schema to Deny in Table Security, so columns can’t be added, renamed, retyped, or removed.', } : { - title: 'Deleting columns is locked', - text: 'Removing a column deletes its value from every row, so it’s blocked while deleting is locked.', + title: 'Deleting rows is disabled', + text: 'Removing a column clears its value from every row, so it needs Deleting Rows set to Allow in Table Security.', } case 'edit-cell': return { - title: 'Editing rows is locked', - text: 'Existing cell values can’t be changed until an admin unlocks this table.', + title: 'Updating rows is disabled', + text: 'An admin has set Updating Rows to Deny in Table Security.', } case 'status': { const nouns = lockedNouns(locks) return { - title: 'Table locks', + title: 'Table Security', text: nouns.length > 0 - ? `An admin has locked ${nouns.join(', ')} on this table.` - : 'Nothing is locked on this table.', + ? `An admin has set ${nouns.join(', ')} to Deny on this table.` + : 'Every action is allowed on this table.', } } } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index a6fbe933f0c..2f3a338ff5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -93,14 +93,19 @@ import { import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' import { columnTypeIcon } from './components/table-grid/headers' import { useTable, useTableEventStream, useTableRoom } from './hooks' -import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy' +import { + type BlockedTableAction, + describeBlockedAction, + LOCK_TOOLTIPS, + lockedNouns, +} from './lock-copy' import { ALL_VIEW_PARAM, DEFAULT_TABLE_DETAIL_SORT_DIRECTION, tableDetailParsers, tableDetailUrlKeys, } from './search-params' -import type { QueryOptions } from './types' +import type { QueryOptions, RowInsertTarget } from './types' import { generateColumnName } from './utils' const logger = createLogger('Table') @@ -226,6 +231,7 @@ export function Table({ const blockedToastIdRef = useRef(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [editingRow, setEditingRow] = useState(null) + const [addRowTarget, setAddRowTarget] = useState(null) const [deletingRows, setDeletingRows] = useState([]) const [deletingAll, setDeletingAll] = useState<{ excludeRowIds: string[] @@ -295,6 +301,7 @@ export function Table({ }, []) const onCloseSlideout = () => dispatch({ type: 'CLOSE' }) const onOpenRowModal = (row: TableRowType) => setEditingRow(row) + const onOpenAddRowModal = (insertAt: RowInsertTarget = {}) => setAddRowTarget(insertAt) // useCallback because is memo-wrapped — these flow into // the breadcrumbs / headerActions memos, whose identity drives that re-render. const onRequestDeleteTable = useCallback(() => setShowDeleteTableConfirm(true), []) @@ -1303,7 +1310,7 @@ export function Table({ ...(userPermissions.canAdmin ? [ { - label: 'Lock settings', + label: 'Table Security', icon: Lock, onClick: () => setShowLockSettings(true), }, @@ -1355,7 +1362,7 @@ export function Table({ description: text, ...(canOpenLockSettings ? { - action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) }, + action: { label: 'Table Security', onClick: () => setShowLockSettings(true) }, // An action would otherwise pin the toast open until dismissed. duration: BLOCKED_TOAST_MS, } @@ -1392,7 +1399,7 @@ export function Table({ ) // A toast's action is captured when it is created, so a viewer who loses - // admin access mid-toast would keep a Lock settings button that opens + // admin access mid-toast would keep a Table Security button that opens // nothing. Dismiss on that transition only — a viewer who never had access // has a legitimate action-less notice that must survive. const couldOpenLockSettingsRef = useRef(canOpenLockSettings) @@ -1434,7 +1441,6 @@ export function Table({ trigger='header' disabled={false} blocked={!canMutateSchema} - onBlocked={() => showBlockedToast('add-column')} onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} @@ -1610,6 +1616,7 @@ export function Table({ onOpenExecutionDetails={onOpenExecutionDetails} onOpenEnrichmentDetails={onOpenEnrichmentDetails} onOpenRowModal={onOpenRowModal} + onOpenAddRowModal={onOpenAddRowModal} onRequestDeleteRows={onRequestDeleteRows} onRequestDeleteAllByFilter={onRequestDeleteAllByFilter} onRequestDeleteColumns={onRequestDeleteColumns} @@ -1714,6 +1721,12 @@ export function Table({ workspaceId={workspaceId} tableId={tableId} onColumnRename={onColumnRename} + readOnly={!canMutateSchema} + readOnlyReason={ + tableData?.locks.schemaLocked + ? LOCK_TOOLTIPS.schema + : 'You don’t have permission to change columns.' + } /> )} + {addRowTarget && tableData && ( + setAddRowTarget(null)} + table={tableData} + insertAt={addRowTarget} + onSuccess={() => setAddRowTarget(null)} + /> + )} {editingRow && tableData && ( setShowLockSettings(false)} workspaceId={workspaceId} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts index 34618913acf..fa026c6ef4d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/types.ts @@ -1,3 +1,4 @@ +import type { InsertTableRowBodyInput } from '@/lib/api/contracts/tables' import type { SortSpec, TablePredicate, TableRow } from '@/lib/table' /** @@ -36,3 +37,9 @@ export interface EditingCell { columnName: string columnKey?: string } + +/** Where a new row goes; an empty target appends it to the end of the table. */ +export type RowInsertTarget = Pick< + InsertTableRowBodyInput, + 'position' | 'afterRowId' | 'beforeRowId' +> diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 22b4359b9f8..25a9fcb56ad 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -159,6 +159,12 @@ export type TableRowsResponse = Pick< interface RowMutationContext { workspaceId: string tableId: string + /** + * Suppresses the error toast for callers that render the failure themselves — + * the row modal shows it inline, and two copies of the same sentence read as + * two separate failures. The cache self-heal on a 423 still runs. + */ + suppressErrorToast?: boolean } type UpdateTableRowParams = Pick & @@ -809,16 +815,22 @@ function notifyRowWriteError(error: Error, onUpgrade: () => void): void { function handleTableLockRejection( error: unknown, queryClient: ReturnType, - tableId: string + tableId: string, + options?: { silent?: boolean } ): boolean { if (!isApiClientError(error) || error.status !== 423) return false void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) void queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) - toast.error(error.message, { duration: 5000 }) + // `silent` only drops the toast; the refetches above are what un-stale the grid. + if (!options?.silent) toast.error(error.message, { duration: 5000 }) return true } -export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) { +export function useCreateTableRow({ + workspaceId, + tableId, + suppressErrorToast, +}: RowMutationContext) { const queryClient = useQueryClient() const router = useRouter() @@ -866,7 +878,9 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) }) }, onError: (error) => { - if (handleTableLockRejection(error, queryClient, tableId)) return + if (handleTableLockRejection(error, queryClient, tableId, { silent: suppressErrorToast })) + return + if (suppressErrorToast) return notifyRowWriteError(error, () => router.push(buildUpgradeHref(workspaceId, 'tables'))) }, onSettled: () => { @@ -1065,7 +1079,11 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon * Update a single row in a table. * Uses optimistic updates for instant UI feedback on inline cell edits. */ -export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) { +export function useUpdateTableRow({ + workspaceId, + tableId, + suppressErrorToast, +}: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ @@ -1150,8 +1168,10 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) if (context?.didBumpRunState) { queryClient.setQueryData(tableKeys.activeDispatches(tableId), context.runStateSnapshot) } - if (handleTableLockRejection(error, queryClient, tableId)) return + if (handleTableLockRejection(error, queryClient, tableId, { silent: suppressErrorToast })) + return if (isValidationError(error)) return + if (suppressErrorToast) return toast.error(error.message, { duration: 5000 }) }, }) @@ -1234,7 +1254,11 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon /** * Delete a single row from a table. */ -export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) { +export function useDeleteTableRow({ + workspaceId, + tableId, + suppressErrorToast, +}: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ @@ -1245,8 +1269,10 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) }) }, onError: (error) => { - if (handleTableLockRejection(error, queryClient, tableId)) return + if (handleTableLockRejection(error, queryClient, tableId, { silent: suppressErrorToast })) + return if (isValidationError(error)) return + if (suppressErrorToast) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -1259,7 +1285,11 @@ export function useDeleteTableRow({ workspaceId, tableId }: RowMutationContext) * Delete multiple rows from a table. * Returns both deleted ids and failure details for partial-failure UI. */ -export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) { +export function useDeleteTableRows({ + workspaceId, + tableId, + suppressErrorToast, +}: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ @@ -1294,8 +1324,10 @@ export function useDeleteTableRows({ workspaceId, tableId }: RowMutationContext) return { deletedRowIds } }, onError: (error) => { - if (handleTableLockRejection(error, queryClient, tableId)) return + if (handleTableLockRejection(error, queryClient, tableId, { silent: suppressErrorToast })) + return if (isValidationError(error)) return + if (suppressErrorToast) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { diff --git a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx index 9370d2fa6ea..d6bec05ca7d 100644 --- a/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx +++ b/packages/emcn/src/components/chip-date-picker/chip-date-picker.tsx @@ -1,12 +1,13 @@ 'use client' -import { forwardRef, useState } from 'react' +import { forwardRef, useContext, useState } from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' import { ChevronDown } from '../../icons' import { cn } from '../../lib/cn' import { Calendar, formatDateLabel, formatDateRangeLabel } from '../calendar/calendar' import { chipVariants, TRIGGER_BORDER_CLASS } from '../chip/chip' import { chipContentLabelClass, chipIconSlotClass } from '../chip/chip-chrome' +import { InsideModalContext } from '../modal/modal' import { OverflowText } from '../overflow-text/overflow-text' import { POPOVER_ANIMATION_CLASSES } from '../popover/popover-animation' @@ -45,6 +46,10 @@ interface ChipDatePickerSingleProps extends ChipDatePickerBaseProps { * defaults to the runtime's local day (mirrors `Calendar`'s `today`). */ today?: string + /** Adds a time-of-day field, emitting `YYYY-MM-DDTHH:mm`; the popover stays open while it is set. */ + showTime?: boolean + /** Label beside the time field when `showTime` is set. Defaults to `Time`. */ + timeLabel?: string } interface ChipDatePickerRangeProps extends ChipDatePickerBaseProps { @@ -67,7 +72,8 @@ export type ChipDatePickerProps = ChipDatePickerSingleProps | ChipDatePickerRang * `chipVariants` (filled + border) and the owned chevron for visual parity with * the other chip field controls; `ghost` renders the bare toolbar pill instead. * - * `mode='single'` (default) commits on day click. `mode='range'` opens the + * `mode='single'` (default) commits on day click; with `showTime` it also emits the + * time of day and stays open while it is set. `mode='range'` opens the * range calendar — start/end staged behind Clear/Cancel/Apply, with optional * time-of-day inputs — and commits via `onRangeChange`. * @@ -89,6 +95,12 @@ const ChipDatePicker = forwardRef( className, } = props + /** + * Inside a modal dialog the calendar must be modal too: a non-modal popover + * portaled to `body` inherits the dialog's `pointer-events: none` body lock + * and cannot be clicked. Outside dialogs it stays non-modal. + */ + const insideModal = useContext(InsideModalContext) const [open, setOpen] = useState(false) const triggerText = @@ -98,7 +110,7 @@ const ChipDatePicker = forwardRef( : formatDateLabel(props.value)) return ( - +