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 f87a5bdbcd1..6f559998503 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/lock-copy.ts @@ -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). */ @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 1c0daa5fed7..a76f01abab5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -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' @@ -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, @@ -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 ? { @@ -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(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]) + + // 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] + ) + + // 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]) + 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, @@ -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. diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 3dee9af1541..f5778e2e687 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -396,6 +396,29 @@ export function ToastProvider({ children }: { children?: ReactNode }) { const [mounted, setMounted] = useState(false) const timersRef = useRef(new Map>()) + /** + * 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) }, []) @@ -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 })) }, []) @@ -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(createToastFn(addToast))