@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 +3 -3
- package/src/components/collection/ReviewDrawer.tsx +24 -2
- package/src/components/collection/__tests__/approve-unlocks.test.tsx +169 -0
- package/src/components/collection/__tests__/review.test.ts +59 -0
- package/src/components/collection/review.ts +27 -2
- package/src/components/unified-table/UnifiedTable.tsx +49 -32
- package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx +234 -0
- package/src/components/unified-table/__tests__/utils/column-visibility.test.ts +97 -0
- package/src/components/unified-table/utils/columnVisibility.ts +105 -0
- package/src/components/unified-table/utils/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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
|
-
/**
|
|
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 ?
|
|
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:
|
|
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:
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
111
|
+
JSON.stringify(resolvedColumnVisibility)
|
|
104
112
|
)
|
|
105
113
|
} catch {
|
|
106
114
|
// Ignore localStorage errors
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
|
-
}, [
|
|
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]:
|
|
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 =>
|
|
129
|
-
|
|
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 ?
|
|
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
|
-
|
|
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={
|
|
470
|
+
columnVisibility={resolvedColumnVisibility}
|
|
454
471
|
onToggleColumnVisibility={toggleColumnVisibility}
|
|
455
472
|
exportEnabled={exportConfig?.enabled}
|
|
456
473
|
exportData={sortedData}
|
package/src/components/unified-table/__tests__/integration/column-visibility-persistence.test.tsx
ADDED
|
@@ -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
|
+
}
|