11'use client'
22
3- import { useCallback , useMemo , useReducer , useRef , useState } from 'react'
3+ import { useCallback , useEffect , useMemo , useReducer , useRef , useState } from 'react'
44import { Chip , ChipConfirmModal , toast } from '@sim/emcn'
55import { Download , Lock , Pencil , Table as TableIcon , Trash , Upload } from '@sim/emcn/icons'
66import { createLogger } from '@sim/logger'
@@ -62,12 +62,7 @@ import {
6262import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
6363import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
6464import { useTable , useTableEventStream , useTableRoom } from './hooks'
65- import {
66- type BlockedTableAction ,
67- describeBlockedAction ,
68- describeLocks ,
69- lockedNouns ,
70- } from './lock-copy'
65+ import { type BlockedTableAction , describeBlockedAction , lockedNouns } from './lock-copy'
7166import {
7267 DEFAULT_TABLE_DETAIL_SORT_DIRECTION ,
7368 tableDetailParsers ,
@@ -636,7 +631,10 @@ export function Table({
636631 if ( ! tableData ) return
637632 if ( blockedToastIdRef . current ) toast . dismiss ( blockedToastIdRef . current )
638633 const { title, text } = describeBlockedAction ( action , tableData . locks )
639- blockedToastIdRef . current = toast . warning ( title , {
634+ // 'status' is the on-open announcement — nothing was refused, so it reads
635+ // as information rather than a warning.
636+ const notify = action === 'status' ? toast . info : toast . warning
637+ blockedToastIdRef . current = notify ( title , {
640638 description : text ,
641639 ...( canOpenLockSettings
642640 ? {
@@ -650,23 +648,48 @@ export function Table({
650648 [ tableData , canOpenLockSettings ]
651649 )
652650
651+ // Announce the lock state once per table on open. Unlike the re-rendering
652+ // permission gates, this fires once and can't self-correct, so it waits for
653+ // `canAdmin` to settle instead of treating loading as permitted.
654+ const announcedLockTableIdRef = useRef < string | null > ( null )
655+ useEffect ( ( ) => {
656+ if ( ! tableData || userPermissions . isLoading ) return
657+ if ( announcedLockTableIdRef . current === tableData . id ) return
658+ announcedLockTableIdRef . current = tableData . id
659+ if ( lockedNouns ( tableData . locks ) . length === 0 ) return
660+ showBlockedToast ( 'status' )
661+ } , [ tableData , userPermissions . isLoading , showBlockedToast ] )
662+
663+ // A notice must not outlive the table it describes — its action targets
664+ // whichever table is current. Keyed on `tableId` so an embedded swap that
665+ // changes the prop without a route change is covered too. Leaving ends the
666+ // visit, so the latch resets and coming back announces again.
667+ useEffect (
668+ ( ) => ( ) => {
669+ announcedLockTableIdRef . current = null
670+ if ( ! blockedToastIdRef . current ) return
671+ toast . dismiss ( blockedToastIdRef . current )
672+ blockedToastIdRef . current = null
673+ } ,
674+ [ tableId ]
675+ )
676+
677+ // A toast's action is captured when it is created, so a viewer who loses
678+ // admin access mid-toast would keep a Lock settings button that opens
679+ // nothing. Dismiss on that transition only — a viewer who never had access
680+ // has a legitimate action-less notice that must survive.
681+ const couldOpenLockSettingsRef = useRef ( canOpenLockSettings )
682+ useEffect ( ( ) => {
683+ const lostAccess = couldOpenLockSettingsRef . current && ! canOpenLockSettings
684+ couldOpenLockSettingsRef . current = canOpenLockSettings
685+ if ( ! lostAccess || ! blockedToastIdRef . current ) return
686+ toast . dismiss ( blockedToastIdRef . current )
687+ blockedToastIdRef . current = null
688+ } , [ canOpenLockSettings ] )
689+
653690 const headerActions = useMemo ( ( ) => {
654691 if ( ! tableData ) return undefined
655- // Header space is for state, not for settings: the chip appears only once
656- // something is actually locked, and names the mode so it reads at a glance.
657- // Reaching the panel on an unlocked table is the dropdown's job.
658- const anyLocked = lockedNouns ( tableData . locks ) . length > 0
659692 return [
660- ...( anyLocked
661- ? [
662- {
663- label : describeLocks ( tableData . locks ) . name ,
664- icon : Lock ,
665- onClick : ( ) =>
666- userPermissions . canAdmin ? setShowLockSettings ( true ) : showBlockedToast ( 'status' ) ,
667- } ,
668- ]
669- : [ ] ) ,
670693 {
671694 label : 'Import CSV' ,
672695 icon : Upload ,
@@ -682,14 +705,7 @@ export function Table({
682705 disabled : tableData . rowCount === 0 ,
683706 } ,
684707 ]
685- } , [
686- tableData ,
687- userPermissions . canEdit ,
688- userPermissions . canAdmin ,
689- handleExportCsv ,
690- onRequestImportCsv ,
691- showBlockedToast ,
692- ] )
708+ } , [ tableData , userPermissions . canEdit , handleExportCsv , onRequestImportCsv ] )
693709
694710 // Adding a column is a schema change. The trigger stays visible when the
695711 // table is schema-locked and explains itself instead of disappearing.
0 commit comments