@startsimpli/ui 0.4.112 → 0.4.113
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.
|
|
3
|
+
"version": "0.4.113",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -123,8 +123,8 @@
|
|
|
123
123
|
"tailwindcss": "^3.4.19",
|
|
124
124
|
"tsup": "^8.5.1",
|
|
125
125
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
126
|
+
"@startsimpli/realtime": "0.0.2",
|
|
127
|
+
"@startsimpli/i18n": "0.1.3"
|
|
128
128
|
},
|
|
129
129
|
"keywords": [
|
|
130
130
|
"ui",
|
|
@@ -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
|
});
|