@startsimpli/ui 0.4.134 → 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 +2 -2
- package/src/components/collection/InlineReviewActions.tsx +31 -16
- package/src/components/collection/ReviewDrawer.tsx +15 -2
- package/src/components/collection/__tests__/review.test.ts +149 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +88 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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,8 +47,8 @@
|
|
|
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/auth": "^0.4.38",
|
|
51
50
|
"@startsimpli/api": "^0.5.66",
|
|
51
|
+
"@startsimpli/auth": "^0.4.38",
|
|
52
52
|
"@startsimpli/i18n": "^0.1.3"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
@@ -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.
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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={
|
|
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
|
+
});
|
|
@@ -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). */
|