Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Single source of truth for lock vocabulary shared by the lock settings modal,
* the blocked-action toast, and the table header chip. Kept out of
* Single source of truth for lock vocabulary shared by the lock settings 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).
*/
Expand Down Expand Up @@ -76,8 +76,8 @@ export function describeLocks(locks: TableLocks): { name: string; detail: string

/**
* Why a locked-table notice was raised. `'status'` is the informational case
* (a non-admin clicking the header lock chip); the rest are actions the user
* just tried and couldn't do.
* (the announcement shown once when a locked 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'

Expand Down
63 changes: 33 additions & 30 deletions apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
Expand Down Expand Up @@ -61,12 +61,7 @@ import {
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
import { useTable, useTableEventStream } from './hooks'
import {
type BlockedTableAction,
describeBlockedAction,
describeLocks,
lockedNouns,
} from './lock-copy'
import { type BlockedTableAction, describeBlockedAction, lockedNouns } from './lock-copy'
import {
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
tableDetailParsers,
Expand Down Expand Up @@ -627,8 +622,16 @@ export function Table({
if (!tableData) return
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
const { title, text } = describeBlockedAction(action, tableData.locks)
blockedToastIdRef.current = toast.warning(title, {
// 'status' is the on-open announcement — nothing was refused, so it reads
// as information rather than a warning.
const notify = action === 'status' ? toast.info : toast.warning
blockedToastIdRef.current = notify(title, {
description: text,
// The provider's route-change sweep runs after child effects, so an
// announcement fired on a warm-cache navigation would be cleared in the
// same commit it was added. These toasts belong to this view, so they
// opt out of the sweep and are dismissed on unmount instead.
persistAcrossRoutes: true,
...(canOpenLockSettings
? {
action: { label: 'Lock settings', onClick: () => setShowLockSettings(true) },
Expand All @@ -641,23 +644,30 @@ export function Table({
[tableData, canOpenLockSettings]
)

// Announce the lock state once per table on open. Unlike the re-rendering
// permission gates, this fires once and can't self-correct, so it waits for
// `canAdmin` to settle instead of treating loading as permitted.
const announcedLockTableIdRef = useRef<string | null>(null)
useEffect(() => {
if (!tableData || userPermissions.isLoading) return
if (announcedLockTableIdRef.current === tableData.id) return
announcedLockTableIdRef.current = tableData.id
if (lockedNouns(tableData.locks).length === 0) return
showBlockedToast('status')
}, [tableData, userPermissions.isLoading, showBlockedToast])
Comment thread
cursor[bot] marked this conversation as resolved.

// Counterpart to `persistAcrossRoutes` above: this view's toasts don't trail
// the user once it goes away.
useEffect(
() => () => {
if (blockedToastIdRef.current) toast.dismiss(blockedToastIdRef.current)
},
[]
)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const headerActions = useMemo(() => {
if (!tableData) return undefined
// Header space is for state, not for settings: the chip appears only once
// something is actually locked, and names the mode so it reads at a glance.
// Reaching the panel on an unlocked table is the dropdown's job.
const anyLocked = lockedNouns(tableData.locks).length > 0
return [
...(anyLocked
? [
{
label: describeLocks(tableData.locks).name,
icon: Lock,
onClick: () =>
userPermissions.canAdmin ? setShowLockSettings(true) : showBlockedToast('status'),
},
]
: []),
{
label: 'Import CSV',
icon: Upload,
Expand All @@ -673,14 +683,7 @@ export function Table({
disabled: tableData.rowCount === 0,
},
]
}, [
tableData,
userPermissions.canEdit,
userPermissions.canAdmin,
handleExportCsv,
onRequestImportCsv,
showBlockedToast,
])
}, [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv])

// Adding a column is a schema change. The trigger stays visible when the
// table is schema-locked and explains itself instead of disappearing.
Expand Down
Loading