@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.112",
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/i18n": "0.1.3",
127
- "@startsimpli/realtime": "0.0.2"
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
- /** 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
  });