Skip to content

Commit 62779c8

Browse files
improvement(tables): surface blocked lock actions as a toast, not a modal
- Replace TableLockedModal with a warning toast carrying a "Lock settings" action button for admins. Being told you can't edit shouldn't cost a dismiss click, and the button still routes admins straight to the panel - Dedupe by id so repeated attempts on a locked cell replace one notice instead of stacking a column of them - Move the copy into lock-copy.ts alongside the rest of the lock vocabulary and tighten it for toast length
1 parent 7a6f376 commit 62779c8

6 files changed

Lines changed: 113 additions & 164 deletions

File tree

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ export * from './sidebar-fields'
1010
export * from './table-action-bar'
1111
export * from './table-filter'
1212
export * from './table-grid'
13-
export * from './table-locked-modal'
1413
export * from './workflow-sidebar'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
import { getColumnId } from '@/lib/table/column-keys'
2222
import { TABLE_LIMITS } from '@/lib/table/constants'
2323
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
24+
import type { BlockedTableAction } from '@/app/workspace/[workspaceId]/tables/[tableId]/lock-copy'
2425
import { useTimezone } from '@/hooks/queries/general-settings'
2526
import {
2627
useAddTableColumn,
@@ -50,7 +51,6 @@ import type { ColumnConfig } from '../column-config-sidebar'
5051
import { ContextMenu } from '../context-menu'
5152
import { NewColumnDropdown } from '../new-column-dropdown'
5253
import { resolveSelectOptions } from '../select-field'
53-
import type { BlockedTableAction } from '../table-locked-modal'
5454
import type { WorkflowConfig } from '../workflow-sidebar'
5555
import { ExpandedCellPopover } from './cells'
5656
import { ADD_COL_WIDTH, COL_WIDTH, SELECTION_TINT_BG } from './constants'

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-locked-modal/table-locked-modal.tsx

Lines changed: 0 additions & 137 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Single source of truth for lock vocabulary shared by the lock settings modal,
3-
* the locked-action modal, and the table header chip. Kept out of
3+
* the blocked-action toast, and the table header chip. Kept out of
44
* `lib/table/mutation-locks.ts` — that module is server-tainted (importing it
55
* from a client component pulls `next/headers` into the browser bundle).
66
*/
@@ -65,3 +65,66 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string
6565
}
6666
return { name: 'Locked', detail: `${locked.join(', ')} locked.` }
6767
}
68+
69+
/**
70+
* Why a locked-table notice was raised. `'status'` is the informational case
71+
* (a non-admin clicking the header lock chip); the rest are actions the user
72+
* just tried and couldn't do.
73+
*/
74+
export type BlockedTableAction = 'add-row' | 'add-column' | 'delete-column' | 'edit-cell' | 'status'
75+
76+
/**
77+
* Copy for the action the user attempted. Explains what is blocked and — for
78+
* the append-only manual-entry case — what to do instead, since that one is
79+
* blocked by the *update* lock rather than the insert lock.
80+
*/
81+
export function describeBlockedAction(
82+
action: BlockedTableAction,
83+
locks: TableLocks
84+
): { title: string; text: string } {
85+
switch (action) {
86+
case 'add-row':
87+
if (locks.insertLocked) {
88+
return {
89+
title: 'Adding rows is locked',
90+
text: 'No new rows can be added until an admin unlocks this table.',
91+
}
92+
}
93+
return {
94+
title: 'This table is append-only',
95+
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.',
96+
}
97+
case 'add-column':
98+
return {
99+
title: 'Changing columns is locked',
100+
text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.',
101+
}
102+
case 'delete-column':
103+
// Reachable with the schema lock off but the delete lock on — removing a
104+
// column clears its value from every row, so it needs both.
105+
return locks.schemaLocked
106+
? {
107+
title: 'Changing columns is locked',
108+
text: 'Columns can’t be added, renamed, retyped, or removed until an admin unlocks this table.',
109+
}
110+
: {
111+
title: 'Deleting columns is locked',
112+
text: 'Removing a column deletes its value from every row, so it’s blocked while deleting is locked.',
113+
}
114+
case 'edit-cell':
115+
return {
116+
title: 'Editing rows is locked',
117+
text: 'Existing cell values can’t be changed until an admin unlocks this table. Workflow and enrichment columns still populate.',
118+
}
119+
case 'status': {
120+
const nouns = lockedNouns(locks)
121+
return {
122+
title: 'Table locks',
123+
text:
124+
nouns.length > 0
125+
? `An admin has locked ${nouns.join(', ')} on this table.`
126+
: 'Nothing is locked on this table.',
127+
}
128+
}
129+
}
130+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
4444
import { useLogDetailsUIStore } from '@/stores/logs/store'
4545
import type { DeletedRowSnapshot } from '@/stores/table/types'
4646
import {
47-
type BlockedTableAction,
4847
type ColumnConfig,
4948
ColumnConfigSidebar,
5049
EnrichmentDetails,
@@ -57,14 +56,18 @@ import {
5756
TableActionBar,
5857
TableFilter,
5958
TableGrid,
60-
TableLockedModal,
6159
type WorkflowConfig,
6260
WorkflowSidebar,
6361
} from './components'
6462
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
6563
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
6664
import { useTable, useTableEventStream } from './hooks'
67-
import { describeLocks, lockedNouns } from './lock-copy'
65+
import {
66+
type BlockedTableAction,
67+
describeBlockedAction,
68+
describeLocks,
69+
lockedNouns,
70+
} from './lock-copy'
6871
import {
6972
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
7073
tableDetailParsers,
@@ -83,6 +86,9 @@ const logger = createLogger('Table')
8386
*/
8487
const tableLocksEnabled = isTruthy(getEnv('NEXT_PUBLIC_TABLE_LOCKS'))
8588

89+
/** Blocked-action toasts carry a button, so they linger past the 5s default. */
90+
const BLOCKED_TOAST_MS = 8000
91+
8692
interface TableProps {
8793
/** When set, the table renders without its page header / breadcrumbs / page-level
8894
* options bar. Used by the mothership chat panel to embed a table inline. */
@@ -169,7 +175,9 @@ export function Table({
169175
const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' })
170176
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
171177
const [showLockSettings, setShowLockSettings] = useState(false)
172-
const [blockedAction, setBlockedAction] = useState<BlockedTableAction | null>(null)
178+
// Id of the last blocked-action toast, so a user who keeps typing into a
179+
// locked cell replaces one notice rather than stacking a column of them.
180+
const blockedToastIdRef = useRef<string | null>(null)
173181
const [isImportCsvOpen, setIsImportCsvOpen] = useState(false)
174182
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
175183
const [deletingRows, setDeletingRows] = useState<DeletedRowSnapshot[]>([])
@@ -602,6 +610,38 @@ export function Table({
602610
]
603611
)
604612

613+
// An admin can always reach the settings on a locked table — clearing locks
614+
// stays allowed with the flag off, so the kill switch can't strand one. With
615+
// the flag off and nothing locked there is nothing to change, so the toast is
616+
// a plain notice with no action.
617+
const canOpenLockSettings =
618+
userPermissions.canAdmin === true &&
619+
(tableLocksEnabled || (tableData ? lockedNouns(tableData.locks).length > 0 : false))
620+
621+
/**
622+
* Explains why a table mutation is unavailable. A toast rather than a modal:
623+
* being told you can't edit shouldn't cost a dismiss click, and admins still
624+
* get a direct route to the settings via the action button.
625+
*/
626+
const showBlockedToast = useCallback(
627+
(action: BlockedTableAction) => {
628+
if (!tableData) return
629+
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
630+
const { title, text } = describeBlockedAction(action, tableData.locks)
631+
blockedToastIdRef.current = toast.warning(title, {
632+
description: text,
633+
...(canOpenLockSettings
634+
? {
635+
action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) },
636+
// An action would otherwise pin the toast open until dismissed.
637+
duration: BLOCKED_TOAST_MS,
638+
}
639+
: {}),
640+
})
641+
},
642+
[tableData, canOpenLockSettings]
643+
)
644+
605645
const headerActions = useMemo(() => {
606646
if (!tableData) return undefined
607647
const anyLocked = lockedNouns(tableData.locks).length > 0
@@ -616,7 +656,7 @@ export function Table({
616656
label: lockLabel,
617657
icon: Lock,
618658
onClick: () =>
619-
userPermissions.canAdmin ? setShowLockSettings(true) : setBlockedAction('status'),
659+
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
620660
},
621661
]
622662
: []),
@@ -641,6 +681,7 @@ export function Table({
641681
userPermissions.canAdmin,
642682
handleExportCsv,
643683
onRequestImportCsv,
684+
showBlockedToast,
644685
])
645686

646687
// Adding a column is a schema change. The trigger stays visible when the
@@ -651,7 +692,7 @@ export function Table({
651692
trigger='header'
652693
disabled={false}
653694
blocked={!canMutateSchema}
654-
onBlocked={() => setBlockedAction('add-column')}
695+
onBlocked={() => showBlockedToast('add-column')}
655696
onPickType={handleAddColumnOfType}
656697
onPickWorkflow={handleAddWorkflowColumn}
657698
onPickEnrichment={onOpenEnrichments}
@@ -775,7 +816,7 @@ export function Table({
775816
tableId={tableId}
776817
embedded={embedded}
777818
locks={tableData?.locks}
778-
onBlockedAction={setBlockedAction}
819+
onBlockedAction={showBlockedToast}
779820
sidebarReservedPx={sidebarReservedPx}
780821
onOpenColumnConfig={onOpenColumnConfig}
781822
onOpenWorkflowConfig={onOpenWorkflowConfig}
@@ -1039,22 +1080,6 @@ export function Table({
10391080
locks={tableData.locks}
10401081
/>
10411082
)}
1042-
{tableData && (
1043-
<TableLockedModal
1044-
action={blockedAction}
1045-
locks={tableData.locks}
1046-
// An admin can always reach the panel on a locked table — clearing
1047-
// locks stays allowed with the flag off, so the kill switch can't
1048-
// strand one. With the flag off and nothing locked there is nothing
1049-
// to change, so they get the same read-only notice as everyone else.
1050-
canAdmin={
1051-
userPermissions.canAdmin === true &&
1052-
(tableLocksEnabled || lockedNouns(tableData.locks).length > 0)
1053-
}
1054-
onClose={() => setBlockedAction(null)}
1055-
onOpenLockSettings={() => setShowLockSettings(true)}
1056-
/>
1057-
)}
10581083
</Resource>
10591084
)
10601085
}

0 commit comments

Comments
 (0)