@startsimpli/ui 0.4.133 → 0.4.135

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.133",
3
+ "version": "0.4.135",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,7 +47,7 @@
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.65",
50
+ "@startsimpli/api": "^0.5.66",
51
51
  "@startsimpli/auth": "^0.4.38",
52
52
  "@startsimpli/i18n": "^0.1.3"
53
53
  },
@@ -5,13 +5,16 @@
5
5
  * startsim-768w.18.15). Renders the (up to) three decisions from the schema as
6
6
  * compact icon buttons ✕ reject · ✓ good · ✎ edit; a click writes status AND
7
7
  * team_verdict together (the coherent pair, same as ReviewDrawer) IN PLACE —
8
- * optimistic, no drawer. The current verdict shows as the active button.
8
+ * optimistic, no drawer.
9
9
  *
10
10
  * The ✎ "edit / needs work" decision opens a small popover with a note box so the
11
11
  * reviewer says HOW it should change — saved to the note attr alongside the
12
12
  * status+verdict move. Meant to live in a table's trailing "Actions" column; it
13
13
  * stops click propagation so acting never triggers the row's drawer-open.
14
14
  *
15
+ * THE ACTIVE BUTTON REPORTS STATUS, NOT THE VERDICT (bd startsim-m7fdm.12) — see
16
+ * `activeDecisionKey` in review.ts for why, and for what that costs.
17
+ *
15
18
  * Generic + client-injected: pass a CollectionClient + the record; defaults match
16
19
  * the OGMC content schema but everything routes through resolveReviewConfig.
17
20
  */
@@ -26,6 +29,7 @@ import { cn } from '../../lib/utils';
26
29
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
27
30
  import { writeData } from './data';
28
31
  import {
32
+ activeDecisionKey,
29
33
  applyDecisionToData,
30
34
  fieldStr,
31
35
  resolveReviewConfig,
@@ -69,26 +73,37 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
69
73
  () => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
70
74
  [cfg],
71
75
  );
72
- // Which decision the record currently reflects — by verdict when the type has
73
- // one (topics), else by status (news: acceptable→approve, rejected→reject). The
74
- // matching button shows active. Optimistically updated on click.
75
- const initialKey = useMemo(() => {
76
- const v = fieldStr(record.data, cfg.verdictAttr);
77
- const s = fieldStr(record.data, cfg.statusName);
78
- return (
79
- decisions.find((d) => (d.verdict ? d.verdict === v : d.status ? d.status === s : false))?.key ??
80
- null
81
- );
82
- }, [record.data, cfg, decisions]);
83
- const [appliedKey, setAppliedKey] = useState<string | null>(initialKey);
76
+ /**
77
+ * Which decision the record's STATE reflects — the button that renders active.
78
+ * Resolved by the shared `activeDecisionKey`, which answers off STATUS wherever
79
+ * the type has a status pipeline, so the AI's `team_verdict` can no longer
80
+ * render as a human's approval (bd startsim-m7fdm.12).
81
+ */
82
+ const storedKey = useMemo(
83
+ () => activeDecisionKey(record.data, cfg, decisions),
84
+ [record.data, cfg, decisions],
85
+ );
86
+ /**
87
+ * Optimistic override, remembered against the exact record snapshot it was made
88
+ * on, so it DROPS the moment a refetch hands this row a new one and the buttons
89
+ * go back to reporting stored state.
90
+ *
91
+ * It used to be `useState(initialKey)` — an initialiser that runs once per
92
+ * mounted component. A table cell's component instance outlives the record in
93
+ * it (a refetch, a re-sort, a page change hands the same cell a different row),
94
+ * and the old state then showed the PREVIOUS row's decision with nothing to
95
+ * correct it. Deriving from the record and overlaying the click is what makes
96
+ * the active state follow the data it claims to describe.
97
+ */
98
+ const [override, setOverride] = useState<{ on: unknown; key: string } | null>(null);
99
+ const appliedKey = override && override.on === record.data ? override.key : storedKey;
84
100
  const [busy, setBusy] = useState<string | null>(null);
85
101
  const [noteOpen, setNoteOpen] = useState(false);
86
102
  const [note, setNote] = useState('');
87
103
 
88
104
  /** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
89
105
  async function apply(d: ReviewDecision, noteText?: string) {
90
- const prev = appliedKey;
91
- setAppliedKey(d.key); // optimistic
106
+ setOverride({ on: record.data, key: d.key }); // optimistic
92
107
  setBusy(d.key);
93
108
  try {
94
109
  // Declared names re-key the whole outgoing blob, so a decision here cannot
@@ -99,7 +114,7 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
99
114
  onSaved?.();
100
115
  return true;
101
116
  } catch (err) {
102
- setAppliedKey(prev); // roll back
117
+ setOverride(null); // roll back to what is stored
103
118
  notify.error(err instanceof Error ? err.message : 'Could not save.');
104
119
  return false;
105
120
  } finally {
@@ -45,6 +45,7 @@ import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
45
45
  import { declaredBlob, readData, writeData } from './data';
46
46
  import { parseSources } from './drafts';
47
47
  import {
48
+ activeDecisionKey,
48
49
  resolveReviewConfig,
49
50
  reviewDecisions,
50
51
  shouldIgnoreShortcut,
@@ -125,7 +126,19 @@ function ReviewDrawerInner({
125
126
  const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null;
126
127
 
127
128
  const status = fieldStr(data, cfg.statusName);
128
- const verdict = fieldStr(data, cfg.verdictAttr);
129
+ /**
130
+ * Which decision the record's STATE reflects — the ringed button in the sticky
131
+ * bar. Off STATUS, not the verdict, for the reason `activeDecisionKey` sets out
132
+ * (bd startsim-m7fdm.12): the drawer's ring had the same defect as the table's
133
+ * tick, so a reviewer opening one of the twenty topics the re-rank agent had
134
+ * marked `good` saw Approve ringed over a record still sitting at `suggested`.
135
+ *
136
+ * Fed the drawer's own optimistic `data`, so a decision rings instantly; and
137
+ * because it reads status, the manual state override below (`overrideStatus`
138
+ * writes status alone, no verdict) now rings the decision it amounts to
139
+ * instead of ringing nothing.
140
+ */
141
+ const activeKey = useMemo(() => activeDecisionKey(data, cfg, decisions), [data, cfg, decisions]);
129
142
  const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
130
143
  const summary = fieldStr(data, cfg.summaryAttr);
131
144
  const rank = fieldStr(data, cfg.rankAttr);
@@ -339,7 +352,7 @@ function ReviewDrawerInner({
339
352
  <DecisionButton
340
353
  key={d.key}
341
354
  decision={d}
342
- active={verdict !== '' && d.verdict === verdict}
355
+ active={d.key === activeKey}
343
356
  busy={busy === `decision:${d.key}`}
344
357
  onClick={() => applyDecision(d)}
345
358
  />
@@ -1,4 +1,6 @@
1
1
  import {
2
+ activeDecisionKey,
3
+ isTerminalStatus,
2
4
  deriveReviewTransitions,
3
5
  resolveReviewConfig,
4
6
  reviewDecisions,
@@ -10,6 +12,7 @@ import {
10
12
  fieldStr,
11
13
  DEFAULT_VERDICTS,
12
14
  } from '../review';
15
+ import type { ReviewDecision } from '../review';
13
16
  import type { AttributeDef, EntityTypeDef } from '../types';
14
17
 
15
18
  function enumAttr(name: string, choices: string[]): AttributeDef {
@@ -343,3 +346,149 @@ describe('holdAfter — decisions that stay on the record', () => {
343
346
  expect(resolveReviewConfig(type, { holdAfter: ['approve'] }).holdAfter).toEqual(['approve']);
344
347
  });
345
348
  });
349
+
350
+ /**
351
+ * The green tick must report the HUMAN's decision, not the AI's verdict (bd
352
+ * startsim-m7fdm.12).
353
+ *
354
+ * The cases are the live OGMC distribution, measured on 171 topics: suggested/-
355
+ * 100, written/good 31, **suggested/good 20**, rejected/bad 16, written/bad 2,
356
+ * written/- 1, suggested/edit 1, and ZERO at `ready`. The twenty at
357
+ * suggested/good are the defect — the n8n re-rank agent wrote `team_verdict`
358
+ * without touching status, and the table drew a darkened green tick over every
359
+ * one of them.
360
+ */
361
+ describe('activeDecisionKey', () => {
362
+ const topicType = type([
363
+ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
364
+ textAttr('team_verdict'),
365
+ textAttr('team_notes'),
366
+ ]);
367
+ const topicCfg = resolveReviewConfig(topicType);
368
+ const topicDecisions = reviewDecisions(topicCfg);
369
+ const key = (data: Record<string, unknown>) => activeDecisionKey(data, topicCfg, topicDecisions);
370
+
371
+ it('a machine-set verdict alone lights NOTHING — the defect, 20 live rows', () => {
372
+ expect(key({ status: 'suggested', team_verdict: 'good' })).toBeNull();
373
+ });
374
+
375
+ it('and nothing lights on the amber one either — the agent writes `edit` too', () => {
376
+ // The same lie one button over: "RESOLVED from edit -> good" is the agent's
377
+ // own prose. Needs work writes status BACK to the entry stage, so it leaves
378
+ // no trace status can tell apart from untouched.
379
+ expect(key({ status: 'suggested', team_verdict: 'edit' })).toBeNull();
380
+ });
381
+
382
+ it('an untouched topic lights nothing — 100 live rows, unchanged', () => {
383
+ expect(key({ status: 'suggested' })).toBeNull();
384
+ });
385
+
386
+ it('approved lights Approve — the state the fix makes reachable', () => {
387
+ expect(key({ status: 'ready' })).toBe('approve');
388
+ // and it does NOT depend on the verdict agreeing, or being present at all.
389
+ expect(key({ status: 'ready', team_verdict: 'bad' })).toBe('approve');
390
+ });
391
+
392
+ it('rejected lights Reject — 16 live rows, unchanged', () => {
393
+ expect(key({ status: 'rejected', team_verdict: 'bad' })).toBe('reject');
394
+ });
395
+
396
+ it('written still lights Approve — forward past approval, 34 live rows', () => {
397
+ // A written topic was approved; rendering it undecided would be the same lie
398
+ // pointing the other way, and with the State column off by default the tick
399
+ // is the only state a row shows.
400
+ expect(key({ status: 'written', team_verdict: 'good' })).toBe('approve');
401
+ expect(key({ status: 'written' })).toBe('approve');
402
+ });
403
+
404
+ it('written/bad lights GREEN where it used to light red — 2 live rows', () => {
405
+ // A DELIBERATE, surprising-direction change. The agent re-marked two
406
+ // already-written topics `bad` ("RE-MARKED (was unset, and it was
407
+ // WRITTEN)"); status says a human approved them and the writer wrote them,
408
+ // and status is the fact the tick reports.
409
+ expect(key({ status: 'written', team_verdict: 'bad' })).toBe('approve');
410
+ });
411
+
412
+ it('is independent of the order the caller holds its decisions in', () => {
413
+ const shuffled = [...topicDecisions].reverse();
414
+ for (const data of [
415
+ { status: 'suggested', team_verdict: 'good' },
416
+ { status: 'ready' },
417
+ { status: 'rejected' },
418
+ { status: 'written' },
419
+ ]) {
420
+ expect(activeDecisionKey(data, topicCfg, shuffled)).toBe(key(data));
421
+ }
422
+ });
423
+
424
+ it('ignores an undeclared status rather than guessing', () => {
425
+ expect(key({ status: 'queued_for_mars' })).toBeNull();
426
+ expect(key({})).toBeNull();
427
+ });
428
+
429
+ /**
430
+ * News Item is the path this must not break: it declares NO team_verdict and
431
+ * passes `verdicts: []`, so it has always resolved by status and must go on
432
+ * doing exactly that. Its enum runs past `acceptable` into outcomes that are
433
+ * NOT consequences of accepting — which is why only TERMINAL stages count
434
+ * forward.
435
+ */
436
+ describe('news_item (no verdict vocabulary) is untouched', () => {
437
+ const newsType = type([
438
+ enumAttr('status', [
439
+ 'new', 'surfaced', 'acceptable', 'cited', 'ignored', 'rejected', 'preview_only',
440
+ ]),
441
+ ]);
442
+ const newsCfg = resolveReviewConfig(newsType, {
443
+ approveStatus: 'acceptable',
444
+ rejectStatus: 'rejected',
445
+ verdicts: [],
446
+ omitNeedsWork: true,
447
+ });
448
+ const newsDecisions = reviewDecisions(newsCfg);
449
+ const nk = (status: string) => activeDecisionKey({ status }, newsCfg, newsDecisions);
450
+
451
+ it('acceptable → Accept, rejected → Reject', () => {
452
+ expect(nk('acceptable')).toBe('approve');
453
+ expect(nk('rejected')).toBe('reject');
454
+ });
455
+
456
+ it('the entry stage and the pre-decision stages light nothing', () => {
457
+ expect(nk('new')).toBeNull();
458
+ expect(nk('surfaced')).toBeNull();
459
+ });
460
+
461
+ it('`ignored` / `preview_only` sit AFTER acceptable and still light nothing', () => {
462
+ // The forward rule is gated on a TERMINAL-reading status for exactly this:
463
+ // being ignored is a different outcome, not a consequence of accepting.
464
+ expect(nk('ignored')).toBeNull();
465
+ expect(nk('preview_only')).toBeNull();
466
+ expect(nk('cited')).toBeNull();
467
+ });
468
+ });
469
+
470
+ it('a type with no status enum at all still resolves by verdict', () => {
471
+ // There, nothing but the buttons themselves writes a verdict, so the verdict
472
+ // IS the decision. `reviewDecisions` yields none without a status pipeline,
473
+ // so this asserts the branch directly.
474
+ const cfg = resolveReviewConfig(type([textAttr('team_verdict')]));
475
+ const decisions: ReviewDecision[] = [
476
+ { key: 'approve', label: 'Approve', status: null, verdict: 'good', tone: 'good', advance: true, shortcut: 'a', promptNote: false },
477
+ { key: 'reject', label: 'Reject', status: null, verdict: 'bad', tone: 'bad', advance: true, shortcut: 'x', promptNote: false },
478
+ ];
479
+ expect(activeDecisionKey({ team_verdict: 'good' }, cfg, decisions)).toBe('approve');
480
+ expect(activeDecisionKey({ team_verdict: 'bad' }, cfg, decisions)).toBe('reject');
481
+ expect(activeDecisionKey({}, cfg, decisions)).toBeNull();
482
+ });
483
+ });
484
+
485
+ describe('isTerminalStatus', () => {
486
+ it('names the end-of-pipeline stages the forward rule keys off', () => {
487
+ for (const s of ['written', 'published', 'sent', 'live', 'closed']) {
488
+ expect(isTerminalStatus(s)).toBe(true);
489
+ }
490
+ for (const s of ['suggested', 'ready', 'rejected', 'ignored', 'preview_only', 'cited']) {
491
+ expect(isTerminalStatus(s)).toBe(false);
492
+ }
493
+ });
494
+ });
@@ -13,6 +13,8 @@ export {
13
13
  resolveReviewConfig,
14
14
  deriveReviewTransitions,
15
15
  reviewDecisions,
16
+ activeDecisionKey,
17
+ isTerminalStatus,
16
18
  decisionLabel,
17
19
  DEFAULT_DECISION_LABELS,
18
20
  applyDecisionToData,
@@ -387,6 +387,94 @@ export function applyDecisionToData(
387
387
  return declaredBlob(next, declaredNames);
388
388
  }
389
389
 
390
+ /** True when a status name reads like the END of a pipeline (published/written/sent). */
391
+ export function isTerminalStatus(status: string): boolean {
392
+ return TERMINAL_RE.test(status);
393
+ }
394
+
395
+ /**
396
+ * Which review decision a record's STATE currently reflects — the key of the
397
+ * button that should render as active, or null when nobody has decided yet.
398
+ *
399
+ * STATUS IS THE DECISION; THE VERDICT IS NOT (bd startsim-m7fdm.12). This used
400
+ * to resolve by verdict first wherever a type declared one, and on OGMC's topics
401
+ * that made the machine's opinion render as the human's: the n8n re-rank agent
402
+ * writes `team_verdict` WITHOUT writing status — deliberately, because
403
+ * startsim-ozpjw.8 stopped an LLM being able to push a record to `ready` on its
404
+ * own say-so — so 20 of 171 live topics sat at `suggested` while the table drew
405
+ * a darkened green tick over them. A reviewer reads that as "I approved this".
406
+ * Nothing had been approved, and a topic at `suggested` is invisible to the
407
+ * auto-write poll, so those twenty looked done and would never be written.
408
+ *
409
+ * The rule, therefore: WHERE A TYPE HAS A STATUS PIPELINE, ONLY STATUS DECIDES
410
+ * what renders as in force. Status is the one field a human's click writes that
411
+ * the pipeline agents do not, so it is the only honest record of a human
412
+ * decision — and that invariant is what guarantees no machine-set verdict can
413
+ * light any button. `team_verdict` keeps being written (it is the re-rank
414
+ * agent's input) and keeps being READABLE as its own column; it just stops
415
+ * impersonating an approval.
416
+ *
417
+ * Three cases, resolved in this order so the answer never depends on the order a
418
+ * caller happens to hold its decisions in:
419
+ *
420
+ * 1. EXACT, past the entry stage — the record's status is where some decision
421
+ * puts it, and that status exists only because a decision wrote it.
422
+ * 2. BACK AT THE ENTRY STAGE — a "Needs work" writes status back to the stage
423
+ * every record starts on, so it leaves nothing status can tell apart from
424
+ * "untouched". On a verdict-bearing type that is rendered as NO decision:
425
+ * the agent writes the `edit` verdict too ("RESOLVED from edit -> good"), so
426
+ * trusting it here would just move the same lie from the green tick to the
427
+ * amber one. On a type with no verdict vocabulary at all (news_item, which
428
+ * passes `verdicts: []`) the status match is all there is, and stands.
429
+ * 3. FORWARD, PAST APPROVAL, TO A TERMINAL STAGE — `ready → written`. The
430
+ * approval is still the decision in force; a written topic rendering as
431
+ * undecided would be the same lie pointing the other way, and with the State
432
+ * column off by default (bd startsim-m7fdm.11) the tick is the only state a
433
+ * row shows. Only TERMINAL stages count: news_item declares `ignored` and
434
+ * `preview_only` after `acceptable`, and neither is a consequence of
435
+ * accepting anything.
436
+ *
437
+ * A type with no status enum at all keeps resolving by verdict — there, nothing
438
+ * but the buttons themselves writes one, so the verdict IS the decision.
439
+ */
440
+ export function activeDecisionKey(
441
+ data: Record<string, unknown> | undefined,
442
+ r: ResolvedReview,
443
+ decisions: ReviewDecision[] = reviewDecisions(r),
444
+ ): string | null {
445
+ const choices = r.statusChoices;
446
+
447
+ // No status pipeline → the verdict is the only place a decision is recorded.
448
+ if (choices.length === 0) {
449
+ const v = fieldStr(data, r.verdictAttr);
450
+ return v ? decisions.find((d) => d.verdict && d.verdict === v)?.key ?? null : null;
451
+ }
452
+
453
+ const status = fieldStr(data, r.statusName);
454
+ const entry = choices[0];
455
+
456
+ // (1) a status past the entry stage is there because a decision wrote it.
457
+ const exact = decisions.find((d) => !!d.status && d.status !== entry && d.status === status);
458
+ if (exact) return exact.key;
459
+
460
+ // (2) back at the entry stage — indistinguishable from untouched, unless the
461
+ // type carries no verdict vocabulary and the status match is all there is.
462
+ const sentBack = decisions.find((d) => !!d.status && d.status === entry);
463
+ if (sentBack && status === entry) return sentBack.verdict ? null : sentBack.key;
464
+
465
+ // (3) forward, past approval, to a terminal stage: still approved.
466
+ const approve = r.transitions.approve
467
+ ? decisions.find((d) => d.status === r.transitions.approve)
468
+ : undefined;
469
+ if (approve) {
470
+ const at = choices.indexOf(status);
471
+ const approveAt = choices.indexOf(r.transitions.approve as string);
472
+ const rejectAt = r.transitions.reject ? choices.indexOf(r.transitions.reject) : -1;
473
+ if (at > approveAt && at !== rejectAt && isTerminalStatus(status)) return approve.key;
474
+ }
475
+ return null;
476
+ }
477
+
390
478
  // ---- small read helpers used by the component (kept here so they're tested) ----
391
479
 
392
480
  /** String read of an attr off a record's data blob ('' when missing). */