@startsimpli/ui 0.4.112 → 0.4.114

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.112",
3
+ "version": "0.4.114",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,9 +47,9 @@
47
47
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
+ "@startsimpli/api": "^0.5.58",
50
51
  "@startsimpli/auth": "^0.4.35",
51
- "@startsimpli/i18n": "^0.1.3",
52
- "@startsimpli/api": "^0.5.58"
52
+ "@startsimpli/i18n": "^0.1.3"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@startsimpli/api": {
@@ -79,7 +79,15 @@ export interface ReviewDrawerProps {
79
79
  /** Refetch the parent + return to review mode. */
80
80
  saved: () => void;
81
81
  }) => ReactNode;
82
- /** App-specific extra content under the record (e.g. a topic's drafts). Optional. */
82
+ /**
83
+ * App-specific extra content under the record (e.g. a topic's drafts).
84
+ * Optional.
85
+ *
86
+ * Handed the record AS DECIDED — the drawer's optimistic `data`, not the
87
+ * `record` prop (bd startsim-t1t1k). An extra slot is where a consumer puts
88
+ * the action a decision UNLOCKS, so it is the one place that most needs to
89
+ * see the decision that was just made.
90
+ */
83
91
  renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
84
92
  }
85
93
 
@@ -119,6 +127,18 @@ function ReviewDrawerInner({
119
127
  const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
120
128
  const summary = fieldStr(data, cfg.summaryAttr);
121
129
  const rank = fieldStr(data, cfg.rankAttr);
130
+ /**
131
+ * The record AS DECIDED — the `record` prop carrying this drawer's own
132
+ * optimistic `data` (bd startsim-t1t1k).
133
+ *
134
+ * Everything else here already renders from `data`; `renderExtra` was the one
135
+ * slot still reading the untouched prop, so a consumer affordance gated on
136
+ * status (OGMC's "Generate drafts", legal only once the topic is approved)
137
+ * kept reading the PRE-decision status and kept refusing. The parent's
138
+ * refetch cannot fix that on its own — it invalidates a list, while the
139
+ * drawer holds a record object the parent chose before the decision existed.
140
+ */
141
+ const decided = useMemo(() => ({ ...record, data }), [record, data]);
122
142
 
123
143
  /** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */
124
144
  async function patch(changes: Record<string, unknown>, key: string, advance = false) {
@@ -304,7 +324,9 @@ function ReviewDrawerInner({
304
324
 
305
325
  <ReviewSources data={data} sourceAttrs={cfg.sourceAttrs} tierByHost={sourceTierByHost} />
306
326
 
307
- {renderExtra ? <div className="mt-6 border-t border-border pt-4">{renderExtra(record, type)}</div> : null}
327
+ {renderExtra ? (
328
+ <div className="mt-6 border-t border-border pt-4">{renderExtra(decided, type)}</div>
329
+ ) : null}
308
330
  </div>
309
331
 
310
332
  <footer className="space-y-3 border-t border-border px-4 py-3">
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Approving a record must leave the reviewer standing on the record they
3
+ * approved, holding whatever the approval unlocked (bd startsim-t1t1k).
4
+ *
5
+ * The report, verbatim: "To create a draft, you first have to approve the
6
+ * Topic, then click out of the window, refresh the page, filter the 'Ready'
7
+ * topics, find the same topic, click approve again and then it generates
8
+ * drafts." Six steps to reach a button that should have been one — and the
9
+ * second approve writes nothing new, so what the reload actually bought was a
10
+ * FRESH RECORD.
11
+ *
12
+ * Two independent mechanisms in this component produced that, and both are
13
+ * pinned below:
14
+ *
15
+ * 1. THE EXTRA SLOT SAW A STALE RECORD. Every other thing the drawer renders
16
+ * — heading, meta chips, the status Select, which decision reads as active
17
+ * — is derived from the optimistic `data` a decision writes. `renderExtra`
18
+ * alone was handed the untouched `record` prop, so the consumer's
19
+ * follow-on affordance (OGMC's "Generate drafts", gated on
20
+ * status === the approve status) kept reading the PRE-approval status and
21
+ * kept refusing. Exactly the state a page reload cures.
22
+ * 2. THE DRAWER MOVED ON. Approve auto-advances, so even once the gate opened
23
+ * it opened on a record that was no longer on screen.
24
+ *
25
+ * Neither test asserts a colour or a class: they assert that the thing the
26
+ * approval unlocked is reachable, for the record that was approved.
27
+ */
28
+ import * as React from 'react';
29
+ import { render, screen, waitFor } from '@testing-library/react';
30
+ import userEvent from '@testing-library/user-event';
31
+
32
+ import { ReviewDrawer } from '../ReviewDrawer';
33
+ import { readData } from '../data';
34
+ import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
35
+
36
+ const TOPIC_TYPE: EntityTypeDef = {
37
+ id: 1,
38
+ key: 'topic',
39
+ label: 'Topic',
40
+ attributes: [
41
+ {
42
+ id: 'status',
43
+ name: 'status',
44
+ dataType: 'enum',
45
+ required: false,
46
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
47
+ },
48
+ { id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
49
+ ],
50
+ };
51
+
52
+ function topic(id: number, title: string): EntityRecord {
53
+ return {
54
+ id,
55
+ entityType: 'topic',
56
+ externalId: null,
57
+ name: title,
58
+ data: { title, status: 'suggested' },
59
+ createdAt: '2026-09-01T00:00:00Z',
60
+ };
61
+ }
62
+
63
+ function page<T>(results: T[]): Paginated<T> {
64
+ return { count: results.length, next: null, previous: null, results };
65
+ }
66
+
67
+ function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
68
+ const updateEntity = jest.fn(async (id: number | string, input: { data?: Record<string, unknown> }) => ({
69
+ ...topic(Number(id), 'x'),
70
+ data: input.data ?? {},
71
+ }));
72
+ return {
73
+ listTypes: jest.fn(async () => page([TOPIC_TYPE])),
74
+ listAllEntities: jest.fn(async () => []),
75
+ updateEntity,
76
+ } as CollectionClient & { updateEntity: jest.Mock };
77
+ }
78
+
79
+ /**
80
+ * The consumer's follow-on affordance, standing in for OGMC's "Generate
81
+ * drafts": legal only once the record has reached the approve status, and it
82
+ * says WHY when it is not — a disabled control the reader cannot explain is the
83
+ * same defect in another shape.
84
+ */
85
+ function GenerateStub(record: EntityRecord) {
86
+ const approved = String(readData(record.data, 'status') ?? '') === 'ready';
87
+ return approved ? (
88
+ <button type="button">Generate drafts</button>
89
+ ) : (
90
+ <span>Approve this topic to generate drafts.</span>
91
+ );
92
+ }
93
+
94
+ function renderDrawer(overrides: Partial<React.ComponentProps<typeof ReviewDrawer>> = {}) {
95
+ const client = stubClient();
96
+ const onNavigate = jest.fn();
97
+ const onSaved = jest.fn();
98
+ const first = topic(1, 'Desalination in Riyadh');
99
+ const utils = render(
100
+ <ReviewDrawer
101
+ client={client}
102
+ type={TOPIC_TYPE}
103
+ records={[first, topic(2, 'A different topic entirely')]}
104
+ record={first}
105
+ onClose={jest.fn()}
106
+ onNavigate={onNavigate}
107
+ onSaved={onSaved}
108
+ renderExtra={GenerateStub}
109
+ {...overrides}
110
+ />,
111
+ );
112
+ return { ...utils, client, onNavigate, onSaved, first };
113
+ }
114
+
115
+ describe('approving a topic unlocks its follow-on action in place', () => {
116
+ it('hands renderExtra the record AS DECIDED, not the stale prop', async () => {
117
+ const user = userEvent.setup();
118
+ const { client } = renderDrawer({ config: { holdAfter: ['approve'] } });
119
+
120
+ // Before: the follow-on is refused, in words.
121
+ expect(screen.getByText('Approve this topic to generate drafts.')).toBeInTheDocument();
122
+
123
+ await user.click(screen.getByRole('button', { name: /Approve/ }));
124
+
125
+ // The write itself carries the coherent pair the pipeline reads.
126
+ await waitFor(() => expect(client.updateEntity).toHaveBeenCalled());
127
+ expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({
128
+ status: 'ready',
129
+ teamVerdict: 'good',
130
+ });
131
+
132
+ // And the drawer's extra slot reflects it WITHOUT a refetch, a reopen, or a
133
+ // second approve — the parent has not sent a new `record` prop at all.
134
+ expect(await screen.findByRole('button', { name: 'Generate drafts' })).toBeInTheDocument();
135
+ expect(screen.queryByText('Approve this topic to generate drafts.')).not.toBeInTheDocument();
136
+ });
137
+
138
+ it('stays on the approved record when the consumer holds that decision', async () => {
139
+ const user = userEvent.setup();
140
+ const { onNavigate, onSaved } = renderDrawer({ config: { holdAfter: ['approve'] } });
141
+
142
+ await user.click(screen.getByRole('button', { name: /Approve/ }));
143
+
144
+ await waitFor(() => expect(onSaved).toHaveBeenCalled());
145
+ expect(onNavigate).not.toHaveBeenCalled();
146
+ // The heading is still the topic that was decided, not the next one.
147
+ expect(screen.getByRole('heading', { name: 'Desalination in Riyadh' })).toBeInTheDocument();
148
+ });
149
+
150
+ it('still advances after a decision the consumer did NOT hold', async () => {
151
+ const user = userEvent.setup();
152
+ const { onNavigate } = renderDrawer({ config: { holdAfter: ['approve'] } });
153
+
154
+ await user.click(screen.getByRole('button', { name: /Reject/ }));
155
+
156
+ await waitFor(() => expect(onNavigate).toHaveBeenCalled());
157
+ expect(onNavigate.mock.calls[0][0].id).toBe(2);
158
+ });
159
+
160
+ it('advances after Approve for a consumer that holds nothing (unchanged default)', async () => {
161
+ const user = userEvent.setup();
162
+ const { onNavigate } = renderDrawer();
163
+
164
+ await user.click(screen.getByRole('button', { name: /Approve/ }));
165
+
166
+ await waitFor(() => expect(onNavigate).toHaveBeenCalled());
167
+ expect(onNavigate.mock.calls[0][0].id).toBe(2);
168
+ });
169
+ });
@@ -284,3 +284,62 @@ describe('decision labels are the consumer’s to name', () => {
284
284
  );
285
285
  });
286
286
  });
287
+
288
+ /**
289
+ * A decision that UNLOCKS a next action on the same record must not scroll that
290
+ * record off the screen (bd startsim-t1t1k).
291
+ *
292
+ * On OGMC, approving a topic is what makes "Generate drafts" legal for it. The
293
+ * drawer then auto-advanced to the next topic, so the control the approval had
294
+ * just unlocked appeared for a record nobody was looking at — and the reviewer's
295
+ * six-step workaround (close, reload, filter to Ready, find it again, approve a
296
+ * second time) is what she reported. Rejecting has no such follow-on, so it
297
+ * still advances: this is opt-in, per decision key, and every type that does not
298
+ * ask for it is byte-identical.
299
+ */
300
+ describe('holdAfter — decisions that stay on the record', () => {
301
+ const type = {
302
+ id: 't',
303
+ key: 'topic',
304
+ label: 'Topic',
305
+ attributes: [
306
+ {
307
+ id: 'a',
308
+ name: 'status',
309
+ dataType: 'enum' as const,
310
+ required: false,
311
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
312
+ },
313
+ ],
314
+ };
315
+
316
+ it('advances after every decision when nothing is held (today’s behaviour)', () => {
317
+ const byKey = Object.fromEntries(
318
+ reviewDecisions(resolveReviewConfig(type)).map((d) => [d.key, d.advance]),
319
+ );
320
+ expect(byKey.approve).toBe(true);
321
+ expect(byKey.reject).toBe(true);
322
+ // "Needs work" already holds — it is meant to be revisited.
323
+ expect(byKey.needs_work).toBe(false);
324
+ });
325
+
326
+ it('holds the named decision and leaves the others advancing', () => {
327
+ const cfg = resolveReviewConfig(type, { holdAfter: ['approve'] });
328
+ const byKey = Object.fromEntries(reviewDecisions(cfg).map((d) => [d.key, d.advance]));
329
+ expect(byKey.approve).toBe(false);
330
+ expect(byKey.reject).toBe(true);
331
+ });
332
+
333
+ it('changes ONLY whether the queue moves on — never what gets written', () => {
334
+ const plain = reviewDecisions(resolveReviewConfig(type));
335
+ const held = reviewDecisions(resolveReviewConfig(type, { holdAfter: ['approve', 'reject'] }));
336
+ expect(held.map((d) => [d.key, d.label, d.status, d.verdict, d.tone, d.shortcut])).toEqual(
337
+ plain.map((d) => [d.key, d.label, d.status, d.verdict, d.tone, d.shortcut]),
338
+ );
339
+ });
340
+
341
+ it('resolves to an empty hold list when the consumer supplies none', () => {
342
+ expect(resolveReviewConfig(type).holdAfter).toEqual([]);
343
+ expect(resolveReviewConfig(type, { holdAfter: ['approve'] }).holdAfter).toEqual(['approve']);
344
+ });
345
+ });
@@ -186,6 +186,24 @@ export interface ReviewConfig {
186
186
  * unchanged, so nothing that does not pass this renders differently.
187
187
  */
188
188
  decisionLabels?: Record<string, string>;
189
+ /**
190
+ * Decision keys after which the drawer STAYS on the record instead of
191
+ * auto-advancing to the next one. Default: none (every decision behaves
192
+ * exactly as it does today).
193
+ *
194
+ * WHY THIS EXISTS (bd startsim-t1t1k). Auto-advance is right for pure triage
195
+ * — decide, move on — but wrong for a decision that UNLOCKS a next action on
196
+ * the same record. On OGMC, approving a topic is precisely what makes
197
+ * "Generate drafts" legal for it; advancing scrolled the newly-legal control
198
+ * off the screen, and the reviewer's workaround was to close the drawer,
199
+ * reload, filter to Ready, find the topic again and approve it a second time.
200
+ * Six steps, and the second approve wrote nothing — what the reload bought
201
+ * was a fresh record.
202
+ *
203
+ * Only the consumer knows whether one of its decisions has a follow-on, so
204
+ * this is theirs to declare. Rejecting has none, so it keeps advancing.
205
+ */
206
+ holdAfter?: string[];
189
207
  }
190
208
 
191
209
  export interface ResolvedReview {
@@ -203,6 +221,7 @@ export interface ResolvedReview {
203
221
  sourceAttrs: string[];
204
222
  omitNeedsWork: boolean;
205
223
  decisionLabels: Record<string, string>;
224
+ holdAfter: string[];
206
225
  }
207
226
 
208
227
  const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
@@ -235,6 +254,7 @@ export function resolveReviewConfig(
235
254
  sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
236
255
  omitNeedsWork: config.omitNeedsWork ?? false,
237
256
  decisionLabels: config.decisionLabels ?? {},
257
+ holdAfter: config.holdAfter ?? [],
238
258
  };
239
259
  }
240
260
 
@@ -284,10 +304,15 @@ export interface ReviewDecision {
284
304
  * A decision only appears when its status target resolves (or, for Needs work,
285
305
  * when there's an entry stage or an edit verdict) — so a two-state or non-workflow
286
306
  * enum simply shows fewer buttons instead of guessing.
307
+ *
308
+ * `holdAfter` overrides the advance flag per decision key (bd startsim-t1t1k) —
309
+ * it changes only whether the queue moves on, never what a decision writes.
287
310
  */
288
311
  export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
289
312
  const verdictOf = (tone: VerdictTone): string | null =>
290
313
  r.verdicts.find((v) => v.tone === tone)?.value ?? null;
314
+ const held = new Set(r.holdAfter ?? []);
315
+ const advances = (key: string) => !held.has(key);
291
316
  const entry = r.statusChoices[0] ?? null;
292
317
  const out: ReviewDecision[] = [];
293
318
 
@@ -298,7 +323,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
298
323
  status: r.transitions.approve,
299
324
  verdict: verdictOf('good'),
300
325
  tone: 'good',
301
- advance: true,
326
+ advance: advances('approve'),
302
327
  shortcut: 'a',
303
328
  promptNote: false,
304
329
  });
@@ -325,7 +350,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
325
350
  status: r.transitions.reject,
326
351
  verdict: verdictOf('bad'),
327
352
  tone: 'bad',
328
- advance: true,
353
+ advance: advances('reject'),
329
354
  shortcut: 'x',
330
355
  promptNote: false,
331
356
  });
@@ -11,6 +11,7 @@ import { TablePagination } from './components/TablePagination'
11
11
  import { StandardTableToolbar } from './components/Toolbar'
12
12
  import { Loader2 } from 'lucide-react'
13
13
  import { cn } from '../../utils'
14
+ import { reconcileColumnVisibility, readStoredColumnVisibility, isForcedVisible } from './utils/columnVisibility'
14
15
 
15
16
  export function UnifiedTable<TData>({
16
17
  data,
@@ -56,31 +57,38 @@ export function UnifiedTable<TData>({
56
57
  return urlState.getURLState()
57
58
  }, []) // Only run once on mount
58
59
 
59
- // Column visibility state
60
- const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() => {
61
- // Try to load from localStorage if persistKey is set
62
- if (columnVisibilityConfig?.persistKey && typeof window !== 'undefined') {
63
- try {
64
- const stored = localStorage.getItem(`table-columns-${columnVisibilityConfig.persistKey}`)
65
- if (stored) {
66
- return JSON.parse(stored)
67
- }
68
- } catch {
69
- // Ignore localStorage errors
70
- }
71
- }
60
+ // Column visibility state.
61
+ //
62
+ // A stored map is never trusted verbatim: it is reconciled against the
63
+ // columns actually on screen, so an attribute declared AFTER the user last
64
+ // saved a choice takes `defaultVisible` instead of being pinned visible for
65
+ // that one browser (bd startsim-7r2ta). See utils/columnVisibility.ts for the
66
+ // three rules — absent takes the default, a stored `false` survives,
67
+ // `alwaysVisible` outranks both.
68
+ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibilityState>(() =>
69
+ reconcileColumnVisibility(
70
+ columns,
71
+ readStoredColumnVisibility(columnVisibilityConfig?.persistKey),
72
+ columnVisibilityConfig
73
+ )
74
+ )
72
75
 
73
- // Initialize default visibility
74
- const initial: ColumnVisibilityState = {}
75
- columns.forEach(col => {
76
- if (columnVisibilityConfig?.defaultVisible) {
77
- initial[col.id] = columnVisibilityConfig.defaultVisible.includes(col.id)
78
- } else {
79
- initial[col.id] = true // Default all visible
80
- }
81
- })
82
- return initial
83
- })
76
+ // Callers that render before their schema lands (a runtime-declared entity
77
+ // type, a lazily loaded column set) hand us a short `columns` array for a
78
+ // render or two, so the reconciliation has to hold for the columns that
79
+ // arrive later as well. Reading through this memo rather than off state means
80
+ // a late column is never visible for a frame before being corrected.
81
+ const resolvedColumnVisibility = useMemo(
82
+ () => reconcileColumnVisibility(columns, columnVisibility, columnVisibilityConfig),
83
+ [columns, columnVisibility, columnVisibilityConfig]
84
+ )
85
+
86
+ useEffect(() => {
87
+ // Identity-stable when nothing changed, so this settles in one pass.
88
+ if (resolvedColumnVisibility !== columnVisibility) {
89
+ setColumnVisibility(resolvedColumnVisibility)
90
+ }
91
+ }, [resolvedColumnVisibility, columnVisibility])
84
92
 
85
93
  // Client-side sorting state (used when no external sorting config is provided)
86
94
  const [localSort, setLocalSort] = useState<SortState>(() => {
@@ -100,13 +108,13 @@ export function UnifiedTable<TData>({
100
108
  try {
101
109
  localStorage.setItem(
102
110
  `table-columns-${columnVisibilityConfig.persistKey}`,
103
- JSON.stringify(columnVisibility)
111
+ JSON.stringify(resolvedColumnVisibility)
104
112
  )
105
113
  } catch {
106
114
  // Ignore localStorage errors
107
115
  }
108
116
  }
109
- }, [columnVisibility, columnVisibilityConfig?.persistKey])
117
+ }, [resolvedColumnVisibility, columnVisibilityConfig?.persistKey])
110
118
 
111
119
  // Toggle column visibility
112
120
  const toggleColumnVisibility = useCallback((columnId: string) => {
@@ -114,9 +122,12 @@ export function UnifiedTable<TData>({
114
122
  if (columnVisibilityConfig?.alwaysVisible?.includes(columnId)) {
115
123
  return
116
124
  }
125
+ // Anything but an explicit `false` is on screen, so that's what a toggle
126
+ // has to turn off — `!prev[id]` would SHOW a column whose id the map has
127
+ // yet to learn, which is the very state this component used to render.
117
128
  setColumnVisibility(prev => ({
118
129
  ...prev,
119
- [columnId]: !prev[columnId]
130
+ [columnId]: prev[columnId] === false
120
131
  }))
121
132
  }, [columnVisibilityConfig?.alwaysVisible])
122
133
 
@@ -125,8 +136,14 @@ export function UnifiedTable<TData>({
125
136
  if (!columnVisibilityConfig?.enabled) {
126
137
  return columns
127
138
  }
128
- return columns.filter(col => columnVisibility[col.id] !== false)
129
- }, [columns, columnVisibility, columnVisibilityConfig?.enabled])
139
+ return columns.filter(col =>
140
+ // "Cannot be hidden" is a promise to the caller, not a default: a stored
141
+ // map written before the column joined that list must not be able to
142
+ // drop it, and the picker won't offer it back.
143
+ isForcedVisible(col, columnVisibilityConfig.alwaysVisible) ||
144
+ resolvedColumnVisibility[col.id] !== false
145
+ )
146
+ }, [columns, resolvedColumnVisibility, columnVisibilityConfig?.enabled, columnVisibilityConfig?.alwaysVisible])
130
147
 
131
148
  // Get hideable columns for the dropdown
132
149
  const hideableColumns = useMemo(() => {
@@ -263,7 +280,7 @@ export function UnifiedTable<TData>({
263
280
  // Get current view state for saving
264
281
  const getCurrentViewState = useCallback((): Omit<SavedView, 'id' | 'name' | 'createdAt'> => {
265
282
  return {
266
- columnVisibility: columnVisibilityConfig?.enabled ? columnVisibility : undefined,
283
+ columnVisibility: columnVisibilityConfig?.enabled ? resolvedColumnVisibility : undefined,
267
284
  columnOrder: columnReorderConfig?.enabled ? columnReorder.columnOrder : undefined,
268
285
  sortBy: currentSortBy,
269
286
  sortDirection: currentSortDirection,
@@ -271,7 +288,7 @@ export function UnifiedTable<TData>({
271
288
  pageSize: paginationConfig?.pageSize,
272
289
  }
273
290
  }, [
274
- columnVisibility,
291
+ resolvedColumnVisibility,
275
292
  columnVisibilityConfig?.enabled,
276
293
  columnReorder.columnOrder,
277
294
  columnReorderConfig?.enabled,
@@ -450,7 +467,7 @@ export function UnifiedTable<TData>({
450
467
  searchDebounceMs={searchConfig?.debounceMs}
451
468
  columnVisibilityEnabled={columnVisibilityConfig?.enabled}
452
469
  hideableColumns={hideableColumns}
453
- columnVisibility={columnVisibility}
470
+ columnVisibility={resolvedColumnVisibility}
454
471
  onToggleColumnVisibility={toggleColumnVisibility}
455
472
  exportEnabled={exportConfig?.enabled}
456
473
  exportData={sortedData}
@@ -0,0 +1,234 @@
1
+ /**
2
+ * A saved column map must not force-show columns declared after it (bd startsim-7r2ta).
3
+ *
4
+ * UnifiedTable seeds `columnVisibility` from localStorage and then keeps a
5
+ * column whose id is missing from that map, because the filter asks
6
+ * `map[id] !== false` and `undefined !== false`. On a runtime-declared schema —
7
+ * every foundry tenant — that means every attribute added AFTER a reviewer last
8
+ * touched the column picker is pinned visible for that reviewer alone, and
9
+ * invisible to anyone checking in a clean browser.
10
+ *
11
+ * The reconciliation has to thread three needles at once:
12
+ * 1. an id ABSENT from the stored map falls back to `defaultVisible`,
13
+ * 2. a stored `false` is a deliberate hide and survives,
14
+ * 3. `alwaysVisible` outranks both.
15
+ */
16
+ import { fireEvent, render, screen, within } from '@testing-library/react'
17
+ import { useState } from 'react'
18
+ import { UnifiedTable } from '../../index'
19
+ import { ColumnConfig } from '../../types'
20
+
21
+ interface Row {
22
+ id: string
23
+ name: string
24
+ }
25
+
26
+ const rows: Row[] = [{ id: '1', name: 'Alpha' }]
27
+
28
+ const NAME: ColumnConfig<Row> = { id: 'name', header: 'Name', accessorKey: 'name' }
29
+ const STATUS: ColumnConfig<Row> = { id: 'status', header: 'Status', accessorKey: 'status' }
30
+ const SCOPE: ColumnConfig<Row> = { id: 'scope_path', header: 'Scope path', accessorKey: 'scope_path' }
31
+ const ACTIONS: ColumnConfig<Row> = { id: '__actions', header: 'Actions' }
32
+
33
+ const PERSIST_KEY = 'records-topic'
34
+ const STORAGE_KEY = `table-columns-${PERSIST_KEY}`
35
+
36
+ function seedStoredMap(map: Record<string, boolean>) {
37
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
38
+ }
39
+
40
+ function readStoredMap(): Record<string, boolean> {
41
+ return JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? '{}')
42
+ }
43
+
44
+ /** Only the headers of the rendered grid — the column picker lists them too. */
45
+ function headers(): string[] {
46
+ return within(screen.getByRole('table'))
47
+ .getAllByRole('columnheader')
48
+ .map((th) => th.textContent?.trim() ?? '')
49
+ }
50
+
51
+ beforeEach(() => {
52
+ window.localStorage.clear()
53
+ })
54
+
55
+ describe('a stored column map is reconciled against the current columns', () => {
56
+ it('hides a column the stored map never heard of when defaults exclude it', () => {
57
+ // The reviewer saved a choice back when the type had name + status. Since
58
+ // then the schema grew a scope_path attribute, which the app does NOT
59
+ // default on.
60
+ seedStoredMap({ name: true, status: true })
61
+
62
+ render(
63
+ <UnifiedTable<Row>
64
+ data={rows}
65
+ columns={[NAME, STATUS, SCOPE]}
66
+ tableId="records"
67
+ getRowId={(r) => r.id}
68
+ columnVisibility={{
69
+ enabled: true,
70
+ alwaysVisible: ['name'],
71
+ defaultVisible: ['name', 'status'],
72
+ persistKey: PERSIST_KEY,
73
+ }}
74
+ />
75
+ )
76
+
77
+ expect(headers()).toEqual(['Name', 'Status'])
78
+ })
79
+
80
+ it('still shows an unheard-of column when the caller defaults everything on', () => {
81
+ // No defaultVisible => "all visible" is the documented default, so an
82
+ // absent id must stay visible. The fix must not turn absence into a hide.
83
+ seedStoredMap({ name: true })
84
+
85
+ render(
86
+ <UnifiedTable<Row>
87
+ data={rows}
88
+ columns={[NAME, STATUS, SCOPE]}
89
+ tableId="records"
90
+ getRowId={(r) => r.id}
91
+ columnVisibility={{ enabled: true, persistKey: PERSIST_KEY }}
92
+ />
93
+ )
94
+
95
+ expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
96
+ })
97
+
98
+ it('keeps a deliberate hide the user saved', () => {
99
+ // status is in defaultVisible, but the stored map says false. Persistence
100
+ // exists precisely so that choice outlives the default.
101
+ seedStoredMap({ name: true, status: false })
102
+
103
+ render(
104
+ <UnifiedTable<Row>
105
+ data={rows}
106
+ columns={[NAME, STATUS, SCOPE]}
107
+ tableId="records"
108
+ getRowId={(r) => r.id}
109
+ columnVisibility={{
110
+ enabled: true,
111
+ defaultVisible: ['name', 'status'],
112
+ persistKey: PERSIST_KEY,
113
+ }}
114
+ />
115
+ )
116
+
117
+ expect(headers()).toEqual(['Name'])
118
+ })
119
+
120
+ it('overrides a stored hide of an always-visible column', () => {
121
+ // The caller added __actions to alwaysVisible after this user had hidden
122
+ // it (or the column was renamed into the always-visible set). An
123
+ // always-visible column that the table drops takes the row actions with it.
124
+ seedStoredMap({ name: false, status: true, __actions: false })
125
+
126
+ render(
127
+ <UnifiedTable<Row>
128
+ data={rows}
129
+ columns={[NAME, STATUS, ACTIONS]}
130
+ tableId="records"
131
+ getRowId={(r) => r.id}
132
+ columnVisibility={{
133
+ enabled: true,
134
+ alwaysVisible: ['name', '__actions'],
135
+ defaultVisible: ['name', 'status'],
136
+ persistKey: PERSIST_KEY,
137
+ }}
138
+ />
139
+ )
140
+
141
+ expect(headers()).toEqual(['Name', 'Status', 'Actions'])
142
+ })
143
+
144
+ it('shows an always-visible column that defaultVisible forgot', () => {
145
+ // Nothing stored at all: the seed path must not let a defaultVisible list
146
+ // that omits an always-visible id hide it.
147
+ render(
148
+ <UnifiedTable<Row>
149
+ data={rows}
150
+ columns={[NAME, STATUS, ACTIONS]}
151
+ tableId="records"
152
+ getRowId={(r) => r.id}
153
+ columnVisibility={{
154
+ enabled: true,
155
+ alwaysVisible: ['name', '__actions'],
156
+ defaultVisible: ['name', 'status'],
157
+ persistKey: PERSIST_KEY,
158
+ }}
159
+ />
160
+ )
161
+
162
+ expect(headers()).toEqual(['Name', 'Status', 'Actions'])
163
+ })
164
+
165
+ it('keeps a hideable:false column that the defaults leave out', () => {
166
+ // `hideable: false` is already excluded from the column picker, so a
167
+ // reconciliation that hid one would strand it: off the table and off the
168
+ // menu that puts it back.
169
+ const pinned: ColumnConfig<Row> = { ...SCOPE, hideable: false }
170
+
171
+ render(
172
+ <UnifiedTable<Row>
173
+ data={rows}
174
+ columns={[NAME, STATUS, pinned]}
175
+ tableId="records"
176
+ getRowId={(r) => r.id}
177
+ columnVisibility={{
178
+ enabled: true,
179
+ defaultVisible: ['name', 'status'],
180
+ persistKey: PERSIST_KEY,
181
+ }}
182
+ />
183
+ )
184
+
185
+ expect(headers()).toEqual(['Name', 'Status', 'Scope path'])
186
+ })
187
+ })
188
+
189
+ describe('columns that arrive after mount', () => {
190
+ function LateSchema() {
191
+ const [cols, setCols] = useState<ColumnConfig<Row>[]>([NAME])
192
+ return (
193
+ <>
194
+ <button onClick={() => setCols([NAME, STATUS, SCOPE])}>load schema</button>
195
+ <UnifiedTable<Row>
196
+ data={rows}
197
+ columns={cols}
198
+ tableId="records"
199
+ getRowId={(r) => r.id}
200
+ columnVisibility={{
201
+ enabled: true,
202
+ alwaysVisible: ['name'],
203
+ defaultVisible: ['name', 'status'],
204
+ persistKey: PERSIST_KEY,
205
+ }}
206
+ />
207
+ </>
208
+ )
209
+ }
210
+
211
+ it('reconciles a column the table had not been handed at mount', () => {
212
+ seedStoredMap({ name: true, status: true })
213
+ render(<LateSchema />)
214
+
215
+ fireEvent.click(screen.getByText('load schema'))
216
+
217
+ expect(headers()).toEqual(['Name', 'Status'])
218
+ })
219
+
220
+ it('does not wipe a saved hide for a column that has not arrived yet', () => {
221
+ // Reconciling by rebuilding the map from `columns` alone would drop
222
+ // status:false while the schema is still in flight, and the persist effect
223
+ // would write that loss straight back to localStorage.
224
+ seedStoredMap({ name: true, status: false })
225
+ render(<LateSchema />)
226
+
227
+ expect(readStoredMap().status).toBe(false)
228
+
229
+ fireEvent.click(screen.getByText('load schema'))
230
+
231
+ expect(readStoredMap().status).toBe(false)
232
+ expect(headers()).toEqual(['Name'])
233
+ })
234
+ })
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Unit cover for the reconciliation UnifiedTable applies to a stored column
3
+ * map (bd startsim-7r2ta). The integration test pins the rendered result; this
4
+ * pins the rules themselves, including the cases a rendered table can't easily
5
+ * show — an id the current schema no longer declares, a corrupt stored value.
6
+ */
7
+ import { reconcileColumnVisibility } from '../../utils/columnVisibility'
8
+
9
+ const cols = [{ id: 'name' }, { id: 'status' }, { id: 'scope_path' }]
10
+
11
+ describe('reconcileColumnVisibility', () => {
12
+ it('falls an unknown id back to defaultVisible, not to visible', () => {
13
+ expect(
14
+ reconcileColumnVisibility(cols, { name: true, status: true }, {
15
+ enabled: true,
16
+ defaultVisible: ['name', 'status'],
17
+ })
18
+ ).toEqual({ name: true, status: true, scope_path: false })
19
+ })
20
+
21
+ it('treats "no defaultVisible" as all-visible for unknown ids', () => {
22
+ expect(
23
+ reconcileColumnVisibility(cols, { name: true }, { enabled: true })
24
+ ).toEqual({ name: true, status: true, scope_path: true })
25
+ })
26
+
27
+ it('preserves a stored false even when the defaults say otherwise', () => {
28
+ expect(
29
+ reconcileColumnVisibility(cols, { status: false }, {
30
+ enabled: true,
31
+ defaultVisible: ['name', 'status', 'scope_path'],
32
+ })
33
+ ).toEqual({ name: true, status: false, scope_path: true })
34
+ })
35
+
36
+ it('forces an always-visible column on over a stored hide', () => {
37
+ expect(
38
+ reconcileColumnVisibility(cols, { name: false, status: false }, {
39
+ enabled: true,
40
+ alwaysVisible: ['name'],
41
+ defaultVisible: ['name'],
42
+ })
43
+ ).toEqual({ name: true, status: false, scope_path: false })
44
+ })
45
+
46
+ it('forces an always-visible column on when defaultVisible omits it', () => {
47
+ expect(
48
+ reconcileColumnVisibility([...cols, { id: '__actions' }], null, {
49
+ enabled: true,
50
+ alwaysVisible: ['__actions'],
51
+ defaultVisible: ['name'],
52
+ })
53
+ ).toEqual({ name: true, status: false, scope_path: false, __actions: true })
54
+ })
55
+
56
+ it('keeps a stored choice for a column the current schema has not declared', () => {
57
+ // Mid-flight schemas hand the table a short `columns` array. Rebuilding the
58
+ // map from `columns` alone would drop the hide, and the persist effect
59
+ // would write that loss back to localStorage.
60
+ expect(
61
+ reconcileColumnVisibility([{ id: 'name' }], { name: true, status: false }, {
62
+ enabled: true,
63
+ defaultVisible: ['name', 'status'],
64
+ })
65
+ ).toEqual({ name: true, status: false })
66
+ })
67
+
68
+ it('ignores non-boolean values in a corrupt stored map', () => {
69
+ expect(
70
+ reconcileColumnVisibility(cols, { name: 'yes', status: null } as never, {
71
+ enabled: true,
72
+ defaultVisible: ['name'],
73
+ })
74
+ ).toEqual({ name: true, status: false, scope_path: false })
75
+ })
76
+
77
+ it('returns the same object when nothing needs reconciling', () => {
78
+ // The mount-time seed feeds this straight into setState on every columns
79
+ // change; a fresh object every time would loop.
80
+ const stored = { name: true, status: false, scope_path: false }
81
+ expect(
82
+ reconcileColumnVisibility(cols, stored, { enabled: true, defaultVisible: ['name'] })
83
+ ).toBe(stored)
84
+ })
85
+
86
+ it('forces a hideable:false column on, since the picker will not offer it back', () => {
87
+ // Same promise as alwaysVisible, and the one the caller is likelier to get
88
+ // wrong: hiding such a column leaves the user no way to restore it.
89
+ expect(
90
+ reconcileColumnVisibility(
91
+ [{ id: 'name' }, { id: 'status' }, { id: 'scope_path', hideable: false }],
92
+ { name: true, scope_path: false },
93
+ { enabled: true, defaultVisible: ['name'] }
94
+ )
95
+ ).toEqual({ name: true, status: false, scope_path: true })
96
+ })
97
+ })
@@ -0,0 +1,105 @@
1
+ import { ColumnVisibilityConfig, ColumnVisibilityState } from '../types'
2
+
3
+ /** The two fields of a column this reconciliation reads. */
4
+ export interface ReconcilableColumn {
5
+ id: string
6
+ /** `false` is the same promise as listing the id in `alwaysVisible`. */
7
+ hideable?: boolean
8
+ }
9
+
10
+ /**
11
+ * Column visibility persists as a map of column id -> boolean, and the table
12
+ * treats a MISSING id as visible (`map[id] !== false`). That is the right
13
+ * reading for a fresh browser, and the wrong one for a stored map: a schema
14
+ * declared at runtime — every foundry tenant — grows attributes after a
15
+ * reviewer last saved a column choice, and each new one lands outside the
16
+ * stored map and is therefore pinned on for that reviewer alone, invisible to
17
+ * anyone checking in a clean session. Bumping the persist key clears it once
18
+ * and the next attribute brings it back (bd startsim-7r2ta).
19
+ *
20
+ * So a stored map is reconciled against the columns actually on screen:
21
+ *
22
+ * - an id the stored map has never heard of takes `defaultVisible`
23
+ * (or stays visible when the caller declared no defaults at all),
24
+ * - a stored `false` is a deliberate hide and is left alone — that is the
25
+ * entire point of persisting,
26
+ * - `alwaysVisible` outranks both, so neither a stale stored `false` nor a
27
+ * `defaultVisible` list that forgot the id can drop the name column or the
28
+ * row-actions column. `hideable: false` carries the same promise and is
29
+ * forced the same way: the column picker already refuses to list such a
30
+ * column, so hiding one would leave the user no way to bring it back.
31
+ *
32
+ * Ids the current `columns` no longer declare are carried through untouched.
33
+ * A caller that renders before its schema arrives hands us a short column list
34
+ * for a render or two, and rebuilding the map from `columns` alone would erase
35
+ * the user's saved hides in that window — and the persist effect would then
36
+ * write the loss back to localStorage.
37
+ *
38
+ * Returns the `stored` object itself when nothing needed changing, so callers
39
+ * can feed the result straight back into `setState` without looping.
40
+ */
41
+ export function reconcileColumnVisibility(
42
+ columns: ReconcilableColumn[],
43
+ stored: ColumnVisibilityState | null | undefined,
44
+ config?: ColumnVisibilityConfig
45
+ ): ColumnVisibilityState {
46
+ const { defaultVisible, alwaysVisible } = config ?? {}
47
+
48
+ const next: ColumnVisibilityState = {}
49
+ let changed = false
50
+
51
+ // Carry the stored choices forward. A non-boolean is a corrupt entry (hand-
52
+ // edited storage, an older shape) and is treated as absent.
53
+ for (const [id, value] of Object.entries(stored ?? {})) {
54
+ if (typeof value === 'boolean') next[id] = value
55
+ else changed = true
56
+ }
57
+
58
+ for (const col of columns) {
59
+ const id = col.id
60
+ const desired = isForcedVisible(col, alwaysVisible)
61
+ ? true
62
+ : id in next
63
+ ? next[id]
64
+ : defaultVisible
65
+ ? defaultVisible.includes(id)
66
+ : true
67
+ if (next[id] !== desired) {
68
+ next[id] = desired
69
+ changed = true
70
+ }
71
+ }
72
+
73
+ return stored && !changed ? stored : next
74
+ }
75
+
76
+ /**
77
+ * A column the user is not allowed to hide — which therefore must not end up
78
+ * hidden by a stale stored map or by a `defaultVisible` list that omits it.
79
+ */
80
+ export function isForcedVisible(
81
+ column: ReconcilableColumn,
82
+ alwaysVisible: string[] | undefined
83
+ ): boolean {
84
+ return column.hideable === false || !!alwaysVisible?.includes(column.id)
85
+ }
86
+
87
+ /**
88
+ * The persisted half of the above: read the map UnifiedTable wrote for
89
+ * `persistKey`, tolerating a browser that has no localStorage (SSR, privacy
90
+ * mode) and a value that no longer parses.
91
+ */
92
+ export function readStoredColumnVisibility(
93
+ persistKey: string | undefined
94
+ ): ColumnVisibilityState | null {
95
+ if (!persistKey || typeof window === 'undefined') return null
96
+ try {
97
+ const stored = window.localStorage.getItem(`table-columns-${persistKey}`)
98
+ if (!stored) return null
99
+ const parsed = JSON.parse(stored)
100
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
101
+ return parsed as ColumnVisibilityState
102
+ } catch {
103
+ return null
104
+ }
105
+ }
@@ -2,3 +2,4 @@ export * from './renderers'
2
2
  export * from './themes'
3
3
  export * from './validation'
4
4
  export * from './export'
5
+ export * from './columnVisibility'