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
76 changes: 46 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,7 +622,10 @@ 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,
...(canOpenLockSettings
? {
Expand All @@ -641,23 +639,48 @@ 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.

// A notice must not outlive the table it describes — its action targets
// whichever table is current. Keyed on `tableId` so an embedded swap that
// changes the prop without a route change is covered too. Leaving ends the
// visit, so the latch resets and coming back announces again.
useEffect(
() => () => {
announcedLockTableIdRef.current = null
if (!blockedToastIdRef.current) return
toast.dismiss(blockedToastIdRef.current)
blockedToastIdRef.current = null
},
[tableId]
)
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// 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
// 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)
useEffect(() => {
const lostAccess = couldOpenLockSettingsRef.current && !canOpenLockSettings
couldOpenLockSettingsRef.current = canOpenLockSettings
if (!lostAccess || !blockedToastIdRef.current) return
toast.dismiss(blockedToastIdRef.current)
blockedToastIdRef.current = null
}, [canOpenLockSettings])
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Comment thread
TheodoreSpeaks 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 +696,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
49 changes: 23 additions & 26 deletions packages/emcn/src/components/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,29 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
const [mounted, setMounted] = useState(false)
const timersRef = useRef(new Map<string, ReturnType<typeof setTimeout>>())

/**
* Clear the previous route's toasts when the route changes. Toasts flagged
* `persistAcrossRoutes` — global, ongoing-state notifications like the
* connection status — survive; page-scoped ones do not.
*
* Done during render rather than in an effect. Effects run child-first, so an
* effect here would also sweep toasts the newly rendered route just raised: a
* toast added from a child's mount effect — which is what happens whenever
* that child's data is already cached — would be appended and filtered out in
* the same commit, before it ever painted. Adjusting state during render runs
* before children render, so only toasts predating the navigation are cleared.
*
* Stays a pure state update: orphaned timers and measured heights are already
* reconciled by the effects below, which key off `toasts`.
*/
const [sweptPathname, setSweptPathname] = useState(pathname)
if (pathname !== sweptPathname) {
setSweptPathname(pathname)
setToasts((prev) =>
prev.some((t) => !t.persistAcrossRoutes) ? prev.filter((t) => t.persistAcrossRoutes) : prev
)
}

useEffect(() => {
setMounted(true)
}, [])
Expand Down Expand Up @@ -459,27 +482,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
setHeights({})
}, [])

/**
* Clear only route-scoped toasts. Toasts flagged `persistAcrossRoutes` —
* global, ongoing-state notifications like the connection status — survive,
* everything else (page-scoped notifications) is cleared on navigation.
*/
const dismissRouteScopedToasts = useCallback(() => {
setToasts((prev) => {
const kept = prev.filter((t) => t.persistAcrossRoutes)
if (kept.length === prev.length) return prev
for (const t of prev) {
if (t.persistAcrossRoutes) continue
const timer = timersRef.current.get(t.id)
if (timer) {
clearTimeout(timer)
timersRef.current.delete(t.id)
}
}
return kept
})
}, [])

const measureToast = useCallback((id: string, height: number) => {
setHeights((prev) => (prev[id] === height ? prev : { ...prev, [id]: height }))
}, [])
Expand Down Expand Up @@ -536,11 +538,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
}
}, [])

/** On navigation, clear route-scoped toasts so they don't trail the user; `persistAcrossRoutes` toasts survive. */
useEffect(() => {
dismissRouteScopedToasts()
}, [pathname, dismissRouteScopedToasts])

/** Held in a ref (seeded once from the stable `addToast`) so the module-level `toast` binds to the live provider. */
const toastFn = useRef<ToastFn>(createToastFn(addToast))

Expand Down
Loading