From f911b7d0059fa35a7b1f8951143d51f7cf9bd9fe Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 15 Sep 2026 10:12:56 -0700
Subject: [PATCH 1/7] fix(tables): block schema-locked column edits and rework
lock settings as Table Security
---
.../column-dropdown/column-dropdown.test.tsx | 31 +++-
.../column-dropdown/column-dropdown.tsx | 43 ++---
.../lock-settings-modal.test.tsx | 155 ++++++++++++++++++
.../lock-settings-modal.tsx | 136 ++++++++-------
.../table-grid/headers/column-header-menu.tsx | 3 +
.../headers/workflow-group-meta-cell.tsx | 7 +-
.../components/table-grid/table-grid.tsx | 18 +-
.../table-grid/table-primitives.tsx | 49 ++++--
.../tables/[tableId]/lock-copy.ts | 45 ++---
.../[workspaceId]/tables/[tableId]/table.tsx | 8 +-
apps/sim/stores/table/security/store.test.ts | 64 ++++++++
apps/sim/stores/table/security/store.ts | 108 ++++++++++++
12 files changed, 536 insertions(+), 131 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.test.tsx
create mode 100644 apps/sim/stores/table/security/store.test.ts
create mode 100644 apps/sim/stores/table/security/store.ts
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..2481852bfff 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,14 @@ 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'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
@@ -30,14 +32,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 +84,46 @@ export function ColumnDropdown({
onPickWorkflow,
onPickEnrichment,
blocked,
- onBlocked,
}: ColumnDropdownProps) {
+ const Icon = blocked ? Lock : Plus
const triggerButton =
trigger === 'header' ? (
) : (
)
if (blocked) {
+ const lockedTrigger = (
+
+ {triggerButton}
+ Changing the table schema is disabled in Table Security.
+
+ )
return trigger === 'inline-header' ? (
- | {triggerButton} |
+ {lockedTrigger} |
) : (
- triggerButton
+ lockedTrigger
)
}
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..6389861fb12
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/lock-settings-modal/lock-settings-modal.test.tsx
@@ -0,0 +1,155 @@
+/**
+ * @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'
+import { useTableSecurityStore } from '@/stores/table/security/store'
+
+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 getSwitch(label: string): HTMLButtonElement {
+ const element = document.querySelector(
+ `button[role="switch"][aria-label="${label}"]`
+ )
+ if (!element) throw new Error(`Missing switch: ${label}`)
+ return element
+}
+
+function clickSwitch(label: string) {
+ act(() => getSwitch(label).click())
+}
+
+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 save() {
+ const button = [...document.querySelectorAll('button')].find(
+ (element) => element.textContent === 'Save'
+ )
+ if (!button) throw new Error('Missing Save button')
+ act(() => button.click())
+}
+
+beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ mutateAsync.mockReturnValue(new Promise(() => {}))
+ useTableSecurityStore.getState().reset()
+ container = document.createElement('div')
+ document.body.appendChild(container)
+ root = createRoot(container)
+})
+
+afterEach(() => {
+ act(() => root.unmount())
+ container.remove()
+})
+
+describe('Table Security', () => {
+ it('hides permissions while disabled and enables all four backend locks by default', () => {
+ render()
+ expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
+ expect(document.querySelector('[role="radiogroup"]')).toBeNull()
+
+ clickSwitch('Enable Table Security')
+ for (const label of LABELS) {
+ expect(getPermission(label, 'Deny').disabled).toBe(false)
+ expect(getPermission(label, 'Allow').disabled).toBe(false)
+ expect(getPermission(label, 'Deny').getAttribute('aria-checked')).toBe('true')
+ expect(getPermission(label, 'Allow').getAttribute('aria-checked')).toBe('false')
+ }
+ save()
+
+ expect(mutateAsync.mock.calls[0][0]).toEqual({
+ tableId: 'table-1',
+ locks: { insertLocked: true, updateLocked: true, deleteLocked: true, schemaLocked: true },
+ })
+ })
+
+ it('inverts existing locks and remembers permissions after disabling, saving, and reopening', async () => {
+ render({ insertLocked: true, updateLocked: true, deleteLocked: false, schemaLocked: true })
+ expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('true')
+ expect(getPermission('Deleting Rows', 'Allow').getAttribute('aria-checked')).toBe('true')
+ expect(getPermission('Updating Rows', 'Deny').getAttribute('aria-checked')).toBe('true')
+
+ selectPermission('Inserting Rows', 'Allow')
+ clickSwitch('Enable Table Security')
+ expect(document.querySelector('[role="radiogroup"]')).toBeNull()
+ let resolveSave!: () => void
+ mutateAsync.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSave = resolve
+ })
+ )
+ save()
+ expect(mutateAsync.mock.calls[0][0]).toEqual({
+ tableId: 'table-1',
+ locks: UNLOCKED_TABLE_LOCKS,
+ })
+ await act(async () => resolveSave())
+
+ render(UNLOCKED_TABLE_LOCKS, false)
+ render()
+ expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
+ expect(document.querySelector('[role="radiogroup"]')).toBeNull()
+ clickSwitch('Enable Table Security')
+ expect(getPermission('Inserting Rows', 'Allow').getAttribute('aria-checked')).toBe('true')
+ expect(getPermission('Updating Rows', 'Deny').getAttribute('aria-checked')).toBe('true')
+ save()
+ expect(mutateAsync.mock.calls[1][0]).toEqual({
+ tableId: 'table-1',
+ locks: { insertLocked: false, updateLocked: true, deleteLocked: false, schemaLocked: true },
+ })
+ })
+
+ it('does not remember unsuccessful changes and discards them on reopen', () => {
+ render()
+ clickSwitch('Enable Table Security')
+ selectPermission('Inserting Rows', 'Allow')
+ save()
+ expect(useTableSecurityStore.getState().preferences['table-1']).toBeUndefined()
+ expect(onClose).not.toHaveBeenCalled()
+
+ render(UNLOCKED_TABLE_LOCKS, false)
+ render()
+ expect(getSwitch('Enable Table Security').getAttribute('aria-checked')).toBe('false')
+ expect(document.querySelector('[role="radiogroup"]')).toBeNull()
+ clickSwitch('Enable Table Security')
+ expect(getPermission('Inserting Rows', 'Deny').getAttribute('aria-checked')).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..6b58f6914bc 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,31 +1,27 @@
'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
- )
-}
+import {
+ getTableSecurityLocks,
+ getTableSecuritySettings,
+ tableSecuritySettingsEqual,
+ useTableSecurityStore,
+} from '@/stores/table/security/store'
interface LockSettingsModalProps {
isOpen: boolean
@@ -36,7 +32,7 @@ interface LockSettingsModalProps {
}
/**
- * Admin-only panel to toggle a table's four mutation locks. Changes are staged
+ * Admin-only panel that sets 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`.
@@ -48,65 +44,93 @@ export function LockSettingsModal({
tableId,
locks,
}: LockSettingsModalProps) {
- const idPrefix = useId()
const updateLocks = useUpdateTableLocks(workspaceId)
+ const preference = useTableSecurityStore((state) => state.preferences[tableId])
+ const setPreference = useTableSecurityStore((state) => state.setPreference)
+ const settings = getTableSecuritySettings(locks, preference)
- // Stage edits locally; reset to the server value each time the modal opens.
- const [draft, setDraft] = useState(locks)
+ const [draft, setDraft] = useState(settings)
const [prevOpen, setPrevOpen] = useState(isOpen)
if (prevOpen !== isOpen) {
setPrevOpen(isOpen)
- if (isOpen) setDraft(locks)
+ if (isOpen) setDraft(settings)
}
- const dirty = !locksEqual(draft, locks)
- const summary = describeLocks(draft)
+ const dirty = !tableSecuritySettingsEqual(draft, settings)
- const handleSave = () => {
+ const handleSave = async () => {
if (!dirty) {
onClose()
return
}
- updateLocks.mutate({ tableId, locks: draft }, { onSuccess: () => onClose() })
+ try {
+ await updateLocks.mutateAsync({ tableId, locks: getTableSecurityLocks(draft) })
+ } catch {
+ return
+ }
+ setPreference(tableId, draft)
+ 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 (
-
-
-
-
- {/* Not `asChild`: the hint is each lock's only explanation, so
- the trigger must be a focusable button for keyboard users. */}
-
-
-
-
- {field.hint}
-
-
-
-
+ setDraft((prev) => ({ ...prev, enabled }))}
+ />
+
+ {draft.enabled &&
+ LOCK_FIELDS.map((field) => (
+
+ {field.label}
+
+
+
+
+
+ {field.hint}
+
+
+
+ }
+ >
+
- setDraft((prev) => ({ ...prev, [field.key]: checked }))
+ onValueChange={(value) =>
+ setDraft((prev) => ({
+ ...prev,
+ allowedActions: { ...prev.allowedActions, [field.kind]: value === 'allow' },
+ }))
}
- />
-
- )
- })}
+ >
+ Deny
+ Allow
+
+
+ ))}
void
+ schemaLocked?: boolean
onInsertLeft: (columnName: string) => void
onInsertRight: (columnName: string) => void
onDeleteColumn: (columnName: string) => void
@@ -122,6 +123,7 @@ export function ColumnOptionsMenu({
column,
deleteLabel,
onOpenConfig,
+ schemaLocked,
onInsertLeft,
onInsertRight,
onDeleteColumn,
@@ -228,7 +230,7 @@ export function ColumnOptionsMenu({
View workflow
)}
- onOpenConfig(column.key)}>
+ onOpenConfig(column.key)}>
Edit column
@@ -281,6 +283,7 @@ interface WorkflowGroupMetaCellProps {
isGroupSelected: boolean
onSelectGroup: (startColIndex: number, size: number) => void
onOpenConfig: (columnName: string) => void
+ schemaLocked?: boolean
onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[], limit?: RunLimit) => void
onInsertLeft?: (columnName: string) => void
onInsertRight?: (columnName: string) => void
@@ -334,6 +337,7 @@ export function WorkflowGroupMetaCell({
isGroupSelected,
onSelectGroup,
onOpenConfig,
+ schemaLocked,
onRunColumn,
onInsertLeft,
onInsertRight,
@@ -539,6 +543,7 @@ export function WorkflowGroupMetaCell({
position={optionsMenuPosition}
column={column}
onOpenConfig={onOpenConfig}
+ schemaLocked={schemaLocked}
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..2da8f90ce0d 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
@@ -705,8 +705,8 @@ export function TableGrid({
// 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 /
+ // row the user can't fill. The control stays visible and explains itself in
+ // a tooltip. Full-row inserts still flow through CSV import / API /
// blocks / Mothership, which the insert lock alone governs server-side.
const canManualAddRow = userPermissions.canEdit && !locks?.insertLocked && !locks?.updateLocked
const canEditCellRef = useRef(canEditCell)
@@ -4746,6 +4746,7 @@ export function TableGrid({
groupName={workflowGroupById.get(g.groupId)?.name}
onSelectGroup={handleGroupSelect}
onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)}
+ schemaLocked={locks?.schemaLocked}
onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined}
hasActiveFilter={Boolean(effectiveFilter)}
selectedRowIds={selectedRowIds}
@@ -4887,6 +4888,7 @@ export function TableGrid({
workflowGroups={tableWorkflowGroups}
sourceInfo={columnSourceInfo.get(column.key)}
onOpenConfig={handleConfigureColumn}
+ schemaLocked={locks?.schemaLocked}
onViewWorkflow={handleViewWorkflow}
onSortColumn={onSortColumn}
onClearSort={onClearSort}
@@ -4907,7 +4909,6 @@ export function TableGrid({
trigger='inline-header'
disabled={addColumnMutation.isPending}
blocked={!canMutateSchema}
- onBlocked={() => onBlockedAction('add-column')}
onPickType={handleAddColumnOfType}
onPickWorkflow={handleAddWorkflowColumn}
onPickEnrichment={onOpenEnrichments}
@@ -5050,7 +5051,16 @@ export function TableGrid({
)}
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && (
-
+
)}
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..16aea98fbaf 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts
@@ -8,11 +8,12 @@
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 locked, phrased to read inside a list. */
noun: string
+ label: string
hint: string
}
@@ -21,25 +22,29 @@ 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.',
+ label: 'Inserting Rows',
+ hint: 'Allow new rows to be added, including through CSV imports, the API, workflows, and Sim. Deny blocks new rows when Table Security is enabled.',
},
{
key: 'updateLocked',
kind: 'update',
noun: 'editing rows',
- hint: 'On: existing cell values cannot be changed. Workflow and enrichment columns still populate.',
+ label: 'Updating Rows',
+ hint: 'Allow existing cell values to be changed. Deny blocks edits when Table Security is enabled. 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 these actions and destructive column changes when Table Security is enabled.',
},
{
key: 'schemaLocked',
kind: 'schema',
noun: 'changing columns',
- hint: 'On: columns cannot be added, renamed, retyped, or removed.',
+ label: 'Changing Table Schema',
+ hint: 'Allow columns to be added, renamed, retyped, or removed. Deny blocks schema changes when Table Security is enabled. Removing or retyping columns also requires Deleting Rows to be set to Allow.',
},
]
@@ -48,32 +53,6 @@ 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.
- */
-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.` }
-}
-
/**
* 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
@@ -127,7 +106,7 @@ export function describeBlockedAction(
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.`
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
index a6fbe933f0c..27b7eb41422 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
@@ -1303,7 +1303,7 @@ export function Table({
...(userPermissions.canAdmin
? [
{
- label: 'Lock settings',
+ label: 'Table Security',
icon: Lock,
onClick: () => setShowLockSettings(true),
},
@@ -1355,7 +1355,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 +1392,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 +1434,6 @@ export function Table({
trigger='header'
disabled={false}
blocked={!canMutateSchema}
- onBlocked={() => showBlockedToast('add-column')}
onPickType={handleAddColumnOfType}
onPickWorkflow={handleAddWorkflowColumn}
onPickEnrichment={onOpenEnrichments}
@@ -1873,6 +1872,7 @@ export function Table({
)}
{tableData && userPermissions.canAdmin && (
setShowLockSettings(false)}
workspaceId={workspaceId}
diff --git a/apps/sim/stores/table/security/store.test.ts b/apps/sim/stores/table/security/store.test.ts
new file mode 100644
index 00000000000..af7fd17504c
--- /dev/null
+++ b/apps/sim/stores/table/security/store.test.ts
@@ -0,0 +1,64 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { beforeEach, describe, expect, it } from 'vitest'
+import { UNLOCKED_TABLE_LOCKS } from '@/lib/table/types'
+import {
+ getTableSecurityLocks,
+ getTableSecuritySettings,
+ type TableSecuritySettings,
+ useTableSecurityStore,
+} from '@/stores/table/security/store'
+
+const PREFERENCE: TableSecuritySettings = {
+ enabled: false,
+ allowedActions: { insert: true, update: false, delete: false, schema: false },
+}
+
+beforeEach(() => useTableSecurityStore.getState().reset())
+
+describe('table security preferences', () => {
+ it('uses current server locks over remembered local permissions', () => {
+ const serverLocks = { ...UNLOCKED_TABLE_LOCKS, insertLocked: true }
+ const settings = getTableSecuritySettings(serverLocks, PREFERENCE)
+ expect(settings.enabled).toBe(true)
+ expect(settings.allowedActions).toEqual({
+ insert: false,
+ update: true,
+ delete: true,
+ schema: true,
+ })
+ expect(getTableSecurityLocks(settings)).toEqual(serverLocks)
+ })
+
+ it('recognizes an external unlock even when the browser previously enabled restrictions', () => {
+ const settings = getTableSecuritySettings(UNLOCKED_TABLE_LOCKS, {
+ ...PREFERENCE,
+ enabled: true,
+ })
+ expect(settings.enabled).toBe(false)
+ expect(settings.allowedActions).toEqual(PREFERENCE.allowedActions)
+ })
+
+ it('keeps security visibly enabled when all actions are allowed', () => {
+ const settings: TableSecuritySettings = {
+ enabled: true,
+ allowedActions: { insert: true, update: true, delete: true, schema: true },
+ }
+ expect(getTableSecurityLocks(settings)).toEqual(UNLOCKED_TABLE_LOCKS)
+ expect(getTableSecuritySettings(UNLOCKED_TABLE_LOCKS, settings).enabled).toBe(true)
+ })
+
+ it('remembers disabled permissions across hydration without sharing them with another table', async () => {
+ useTableSecurityStore.getState().setPreference('table-1', PREFERENCE)
+ const saved = localStorage.getItem('table-security-preferences')
+ useTableSecurityStore.getState().reset()
+ localStorage.setItem('table-security-preferences', saved!)
+ await useTableSecurityStore.persist.rehydrate()
+ expect(useTableSecurityStore.getState().preferences['table-1']).toEqual(PREFERENCE)
+ expect(useTableSecurityStore.getState().preferences['table-2']).toBeUndefined()
+ expect(getTableSecurityLocks(useTableSecurityStore.getState().preferences['table-1'])).toEqual(
+ UNLOCKED_TABLE_LOCKS
+ )
+ })
+})
diff --git a/apps/sim/stores/table/security/store.ts b/apps/sim/stores/table/security/store.ts
new file mode 100644
index 00000000000..401eb0d9cb9
--- /dev/null
+++ b/apps/sim/stores/table/security/store.ts
@@ -0,0 +1,108 @@
+'use client'
+
+import { create } from 'zustand'
+import { devtools, persist } from 'zustand/middleware'
+import { BrowserStorage } from '@/lib/core/utils/browser-storage'
+import {
+ TABLE_LOCK_FLAGS,
+ TABLE_LOCK_KINDS,
+ type TableLockKind,
+ type TableLocks,
+} from '@/lib/table/types'
+import { registerUserDataReset } from '@/stores/user-data-reset-registry'
+
+export interface TableSecuritySettings {
+ enabled: boolean
+ allowedActions: Record
+}
+
+interface TableSecurityState {
+ preferences: Record
+ setPreference: (tableId: string, settings: TableSecuritySettings) => void
+ reset: () => void
+}
+
+const DEFAULT_SETTINGS: TableSecuritySettings = {
+ enabled: false,
+ allowedActions: { insert: false, update: false, delete: false, schema: false },
+}
+
+/** Maps Table Security settings to the backend lock flags. */
+export function getTableSecurityLocks(settings: TableSecuritySettings): TableLocks {
+ return {
+ insertLocked: settings.enabled && !settings.allowedActions.insert,
+ updateLocked: settings.enabled && !settings.allowedActions.update,
+ deleteLocked: settings.enabled && !settings.allowedActions.delete,
+ schemaLocked: settings.enabled && !settings.allowedActions.schema,
+ }
+}
+
+/**
+ * Server locks are authoritative. When none are set, the browser preference
+ * supplies the remembered per-action choices and distinguishes
+ * enabled-with-everything-allowed from disabled, which both use four false
+ * backend flags.
+ */
+export function getTableSecuritySettings(
+ locks: TableLocks,
+ preference?: TableSecuritySettings
+): TableSecuritySettings {
+ if (TABLE_LOCK_KINDS.some((kind) => locks[TABLE_LOCK_FLAGS[kind]])) {
+ return {
+ enabled: true,
+ allowedActions: {
+ insert: !locks.insertLocked,
+ update: !locks.updateLocked,
+ delete: !locks.deleteLocked,
+ schema: !locks.schemaLocked,
+ },
+ }
+ }
+
+ if (!preference) return DEFAULT_SETTINGS
+ return {
+ enabled:
+ preference.enabled && TABLE_LOCK_KINDS.every((kind) => preference.allowedActions[kind]),
+ allowedActions: preference.allowedActions,
+ }
+}
+
+export function tableSecuritySettingsEqual(
+ a: TableSecuritySettings,
+ b: TableSecuritySettings
+): boolean {
+ return (
+ a.enabled === b.enabled &&
+ TABLE_LOCK_KINDS.every((kind) => a.allowedActions[kind] === b.allowedActions[kind])
+ )
+}
+
+/** Device-local presentation preferences; actual locks are owned by React Query. */
+export const useTableSecurityStore = create()(
+ devtools(
+ persist(
+ (set) => ({
+ preferences: {},
+ setPreference: (tableId, settings) =>
+ set((state) => ({ preferences: { ...state.preferences, [tableId]: settings } })),
+ reset: () => set({ preferences: {} }),
+ }),
+ {
+ name: 'table-security-preferences',
+ partialize: (state) => ({ preferences: state.preferences }),
+ storage: {
+ getItem: (name) => BrowserStorage.getItem(name, null),
+ setItem: (name, value) => {
+ BrowserStorage.setItem(name, value)
+ },
+ removeItem: (name) => {
+ BrowserStorage.removeItem(name)
+ },
+ },
+ }
+ ),
+ { name: 'table-security-preferences' }
+ )
+)
+
+registerUserDataReset('table-security-preferences', () => useTableSecurityStore.getState().reset())
From 1dbc256dfad85ea1638e1e77aec73d72ab1d6a43 Mon Sep 17 00:00:00 2001
From: Justin Blumencranz <96924014+j15z@users.noreply.github.com>
Date: Tue, 15 Sep 2026 12:14:19 -0700
Subject: [PATCH 2/7] fix(tables): read-only cell editors and add-row form for
update-locked tables
- Update-locked tables open cell editors read-only; the expanded editor disables Save with the reason on hover, and empty cells that can't be edited open nothing
- New row opens the Add Row form when only updates are locked
- Row form reads and writes values by column id and uses one date-and-time picker
- ChipDatePicker follows InsideModalContext so its calendar is clickable in modals, and gains showTime/timeLabel in single mode
---
.../components/row-modal/row-modal.test.tsx | 139 +++++++++++++----
.../components/row-modal/row-modal.tsx | 109 ++++++++-----
.../table-grid/cells/cell-content.tsx | 4 +
.../cells/expanded-cell-popover.test.tsx | 144 ++++++++++++++++++
.../cells/expanded-cell-popover.tsx | 29 +++-
.../table-grid/cells/inline-editors.test.ts | 63 ++++++++
.../table-grid/cells/inline-editors.tsx | 62 +++++---
.../components/table-grid/data-row.tsx | 5 +
.../components/table-grid/table-grid.tsx | 82 ++++++----
.../tables/[tableId]/lock-copy.ts | 2 +-
.../[workspaceId]/tables/[tableId]/table.tsx | 12 ++
.../chip-date-picker/chip-date-picker.tsx | 22 ++-
12 files changed, 543 insertions(+), 130 deletions(-)
create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.test.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
index 18573ba0f98..881c7fcbb22 100644
--- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal.test.tsx
@@ -7,14 +7,21 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TableInfo, TableRow } from '@/lib/table'
import { RowModal } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/row-modal/row-modal'
-const { mockToastError, mockUseTimezoneState, mockUpdateRow, mockDeleteRow, mockDeleteRows } =
- vi.hoisted(() => ({
- 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,85 @@ 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()
+ })
+})
+
+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 +217,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 +236,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 +276,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 +284,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 +309,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
)
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..d787309a009 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,24 @@ 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 { todayAtTtlOffset, ttlValueFromPicker, ttlValueToPickerParts } from '@/lib/table/ttl-values'
import { getTimezoneEditBlockedMessage } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/timezone-editing'
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,7 +44,7 @@ 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
@@ -56,12 +62,13 @@ function cleanRowData(
const cleanData: Record = {}
columns.forEach((col) => {
- const value = rowData[col.name]
+ const columnId = getColumnId(col)
+ const value = rowData[columnId]
if (columnTypeOf(col).editor === 'date' && !dateEditorsReady) {
return
}
try {
- cleanData[col.name] = cleanCellValue(value, col, timeZone)
+ cleanData[columnId] = cleanCellValue(value, col, timeZone)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
@@ -71,10 +78,12 @@ function cleanRowData(
}
/**
- * 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.
*/
@@ -97,11 +106,16 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
mode === 'edit' && row ? row.data : {}
)
const [error, setError] = useState(null)
+ const createRowMutation = useCreateTableRow({ workspaceId, tableId })
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
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(
@@ -116,14 +130,17 @@ export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess
try {
const cleanData = cleanRowData(columns, rowData, timeZone, dateEditorsReady)
- if (row) {
+ if (isAddMode) {
+ await createRowMutation.mutateAsync({ data: cleanData })
+ } else if (row) {
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 +199,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'}