@startsimpli/ui 0.4.61 → 0.4.62
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
|
@@ -2,30 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Review-first record drawer — the fast triage surface for a status-carrying
|
|
5
|
-
* entity type (bd startsim-768w.18.14).
|
|
5
|
+
* entity type (bd startsim-768w.18.14 / .15).
|
|
6
6
|
*
|
|
7
|
-
* Opens READ-OPTIMISED, not as an edit form: the record's title / summary /
|
|
8
|
-
* up top, then a sticky
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
7
|
+
* Opens READ-OPTIMISED, not as an edit form: the record's title / summary /
|
|
8
|
+
* sources up top, then a sticky bar with ONE decision — Approve / Needs work /
|
|
9
|
+
* Reject. Each decision writes BOTH underlying fields coherently (status = the
|
|
10
|
+
* pipeline gate + team_verdict = the re-rank signal) in a single save, so there's
|
|
11
|
+
* no separate "verdict" row duplicating Approve/Reject. Approve/Reject advance to
|
|
12
|
+
* the next record; the current state shows as a badge that doubles as the rare
|
|
13
|
+
* manual override (mark written, un-reject). ↑/↓ (or j/k) walk the queue; a / w /
|
|
14
|
+
* x / e are bare-letter shortcuts (guarded while typing). Deep field-editing stays
|
|
15
|
+
* behind "Edit fields" (the app injects its form via renderEditFields).
|
|
14
16
|
*
|
|
15
|
-
* Generic + client-injected: the consumer passes a CollectionClient
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* nothing here is tenant-specific.
|
|
17
|
+
* Generic + client-injected: the consumer passes a CollectionClient, the ordered
|
|
18
|
+
* queue (prev/next), and optional ReviewConfig / render-props. Defaults match the
|
|
19
|
+
* OGMC content schema but everything is a prop — nothing here is tenant-specific.
|
|
19
20
|
*/
|
|
20
21
|
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
21
22
|
import {
|
|
22
23
|
ChevronLeft,
|
|
23
24
|
ChevronRight,
|
|
24
|
-
ThumbsUp,
|
|
25
|
-
ThumbsDown,
|
|
26
|
-
PencilLine,
|
|
27
25
|
Check,
|
|
28
26
|
X,
|
|
27
|
+
PencilLine,
|
|
29
28
|
ExternalLink,
|
|
30
29
|
ShieldCheck,
|
|
31
30
|
MessageSquarePlus,
|
|
@@ -33,6 +32,13 @@ import {
|
|
|
33
32
|
|
|
34
33
|
import { Button } from '../ui/button';
|
|
35
34
|
import { Textarea } from '../ui/textarea';
|
|
35
|
+
import {
|
|
36
|
+
Select,
|
|
37
|
+
SelectContent,
|
|
38
|
+
SelectItem,
|
|
39
|
+
SelectTrigger,
|
|
40
|
+
SelectValue,
|
|
41
|
+
} from '../ui/select';
|
|
36
42
|
import { notify } from '../toast/use-notify';
|
|
37
43
|
import { cn } from '../../lib/utils';
|
|
38
44
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
@@ -40,11 +46,12 @@ import { readData, toCamelKey } from './data';
|
|
|
40
46
|
import { parseSources } from './drafts';
|
|
41
47
|
import {
|
|
42
48
|
resolveReviewConfig,
|
|
49
|
+
reviewDecisions,
|
|
43
50
|
shouldIgnoreShortcut,
|
|
44
51
|
fieldStr,
|
|
45
52
|
reviewHostOf,
|
|
46
53
|
type ReviewConfig,
|
|
47
|
-
type
|
|
54
|
+
type ReviewDecision,
|
|
48
55
|
} from './review';
|
|
49
56
|
|
|
50
57
|
export interface ReviewDrawerProps {
|
|
@@ -95,6 +102,7 @@ function ReviewDrawerInner({
|
|
|
95
102
|
renderExtra,
|
|
96
103
|
}: ReviewDrawerProps & { record: EntityRecord }) {
|
|
97
104
|
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
105
|
+
const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
|
|
98
106
|
const [mode, setMode] = useState<'review' | 'edit'>('review');
|
|
99
107
|
// Local optimistic copy so a click reflects instantly (the parent also refetches).
|
|
100
108
|
const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
|
|
@@ -139,25 +147,37 @@ function ReviewDrawerInner({
|
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
149
|
|
|
142
|
-
|
|
143
|
-
|
|
150
|
+
/** One decision → write status AND verdict together (the coherent pair). */
|
|
151
|
+
function applyDecision(d: ReviewDecision) {
|
|
152
|
+
const changes: Record<string, unknown> = {};
|
|
153
|
+
if (d.status) changes[cfg.statusName] = d.status;
|
|
154
|
+
if (d.verdict) changes[cfg.verdictAttr] = d.verdict;
|
|
155
|
+
if (d.promptNote) setNoteOpen(true);
|
|
156
|
+
void patch(changes, `decision:${d.key}`, d.advance);
|
|
144
157
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
158
|
+
|
|
159
|
+
/** Manual state override from the badge — status only, no verdict, no advance. */
|
|
160
|
+
function overrideStatus(value: string) {
|
|
161
|
+
if (value === status) return;
|
|
162
|
+
void patch({ [cfg.statusName]: value }, `status:${value}`, false);
|
|
150
163
|
}
|
|
164
|
+
|
|
151
165
|
async function saveNote() {
|
|
152
166
|
await patch({ [cfg.noteAttr]: note.trim() }, 'note');
|
|
153
167
|
setNoteOpen(false);
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
// Keyboard: walk the queue + one-key
|
|
170
|
+
// Keyboard: walk the queue + one-key decide. Guarded so it never fires while typing.
|
|
157
171
|
useEffect(() => {
|
|
158
172
|
if (mode !== 'review') return;
|
|
159
173
|
function onKey(e: KeyboardEvent) {
|
|
160
174
|
if (shouldIgnoreShortcut(e)) return;
|
|
175
|
+
const d = decisions.find((x) => x.shortcut === e.key);
|
|
176
|
+
if (d) {
|
|
177
|
+
e.preventDefault();
|
|
178
|
+
applyDecision(d);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
161
181
|
switch (e.key) {
|
|
162
182
|
case 'j':
|
|
163
183
|
case 'ArrowDown':
|
|
@@ -167,18 +187,16 @@ function ReviewDrawerInner({
|
|
|
167
187
|
case 'ArrowUp':
|
|
168
188
|
if (prev) { e.preventDefault(); onNavigate(prev); }
|
|
169
189
|
break;
|
|
170
|
-
case 'a': e.preventDefault(); approve(); break;
|
|
171
|
-
case 'x': e.preventDefault(); reject(); break;
|
|
172
190
|
case 'e': e.preventDefault(); setMode('edit'); break;
|
|
173
|
-
case 'g': e.preventDefault(); setVerdict('good'); break;
|
|
174
|
-
case 'b': e.preventDefault(); setVerdict('bad'); break;
|
|
175
191
|
case 'Escape': e.preventDefault(); onClose(); break;
|
|
176
192
|
}
|
|
177
193
|
}
|
|
178
194
|
window.addEventListener('keydown', onKey);
|
|
179
195
|
return () => window.removeEventListener('keydown', onKey);
|
|
180
196
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
-
}, [mode, prev, next,
|
|
197
|
+
}, [mode, prev, next, decisions, data]);
|
|
198
|
+
|
|
199
|
+
const legend = [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ');
|
|
182
200
|
|
|
183
201
|
return (
|
|
184
202
|
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
|
|
@@ -194,6 +212,21 @@ function ReviewDrawerInner({
|
|
|
194
212
|
) : null}
|
|
195
213
|
</div>
|
|
196
214
|
<div className="flex shrink-0 items-center gap-1">
|
|
215
|
+
{/* Current state — and the rare manual override (mark written, un-reject). */}
|
|
216
|
+
{cfg.statusChoices.length > 0 ? (
|
|
217
|
+
<Select value={status || undefined} onValueChange={overrideStatus}>
|
|
218
|
+
<SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
|
|
219
|
+
<SelectValue placeholder="State…" />
|
|
220
|
+
</SelectTrigger>
|
|
221
|
+
<SelectContent>
|
|
222
|
+
{cfg.statusChoices.map((s) => (
|
|
223
|
+
<SelectItem key={s} value={s} className="capitalize">
|
|
224
|
+
{s}
|
|
225
|
+
</SelectItem>
|
|
226
|
+
))}
|
|
227
|
+
</SelectContent>
|
|
228
|
+
</Select>
|
|
229
|
+
) : null}
|
|
197
230
|
<button
|
|
198
231
|
onClick={() => onNavigate(prev!)}
|
|
199
232
|
disabled={!prev}
|
|
@@ -256,11 +289,6 @@ function ReviewDrawerInner({
|
|
|
256
289
|
rank {rank}
|
|
257
290
|
</span>
|
|
258
291
|
) : null}
|
|
259
|
-
{status ? (
|
|
260
|
-
<span className="rounded-full bg-muted px-2 py-0.5 capitalize text-muted-foreground">
|
|
261
|
-
{status}
|
|
262
|
-
</span>
|
|
263
|
-
) : null}
|
|
264
292
|
{cfg.metaAttrs.map((a) => {
|
|
265
293
|
const v = fieldStr(data, a);
|
|
266
294
|
if (!v) return null;
|
|
@@ -280,30 +308,32 @@ function ReviewDrawerInner({
|
|
|
280
308
|
</div>
|
|
281
309
|
|
|
282
310
|
<footer className="space-y-3 border-t border-border px-4 py-3">
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
busy={busy === `verdict:${v.value}`}
|
|
293
|
-
onClick={() => setVerdict(v.value)}
|
|
311
|
+
{decisions.length > 0 ? (
|
|
312
|
+
<div className="flex items-stretch gap-2">
|
|
313
|
+
{decisions.map((d) => (
|
|
314
|
+
<DecisionButton
|
|
315
|
+
key={d.key}
|
|
316
|
+
decision={d}
|
|
317
|
+
active={verdict !== '' && d.verdict === verdict}
|
|
318
|
+
busy={busy === `decision:${d.key}`}
|
|
319
|
+
onClick={() => applyDecision(d)}
|
|
294
320
|
/>
|
|
295
321
|
))}
|
|
296
322
|
</div>
|
|
323
|
+
) : null}
|
|
324
|
+
|
|
325
|
+
<div className="flex items-center gap-2">
|
|
297
326
|
<button
|
|
298
327
|
onClick={() => setNoteOpen((o) => !o)}
|
|
299
328
|
className={cn(
|
|
300
|
-
'
|
|
329
|
+
'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
|
|
301
330
|
note && 'text-primary-700',
|
|
302
331
|
)}
|
|
303
332
|
>
|
|
304
333
|
<MessageSquarePlus className="h-3.5 w-3.5" />
|
|
305
|
-
{note ? 'Note ✓' : '
|
|
334
|
+
{note ? 'Note ✓' : 'Add note'}
|
|
306
335
|
</button>
|
|
336
|
+
<span className="ml-auto text-[11px] text-muted-foreground">{legend}</span>
|
|
307
337
|
</div>
|
|
308
338
|
|
|
309
339
|
{noteOpen ? (
|
|
@@ -312,8 +342,9 @@ function ReviewDrawerInner({
|
|
|
312
342
|
value={note}
|
|
313
343
|
onChange={(e) => setNote(e.target.value)}
|
|
314
344
|
rows={2}
|
|
315
|
-
placeholder="
|
|
345
|
+
placeholder="Why? — e.g. “good topic, angle too broad; needs a fresher source.”"
|
|
316
346
|
className="text-sm"
|
|
347
|
+
autoFocus
|
|
317
348
|
/>
|
|
318
349
|
<div className="flex gap-2">
|
|
319
350
|
<Button size="sm" onClick={saveNote} disabled={busy === 'note'}>
|
|
@@ -334,36 +365,6 @@ function ReviewDrawerInner({
|
|
|
334
365
|
) : note ? (
|
|
335
366
|
<p className="border-l-2 border-border pl-2 text-xs italic text-muted-foreground">{note}</p>
|
|
336
367
|
) : null}
|
|
337
|
-
|
|
338
|
-
{/* Decide */}
|
|
339
|
-
<div className="flex items-center gap-2">
|
|
340
|
-
{cfg.transitions.approve ? (
|
|
341
|
-
<Button
|
|
342
|
-
size="sm"
|
|
343
|
-
onClick={approve}
|
|
344
|
-
disabled={busy === 'approve'}
|
|
345
|
-
className="flex-1 bg-emerald-600 hover:bg-emerald-700"
|
|
346
|
-
>
|
|
347
|
-
<Check className="mr-1.5 h-4 w-4" />
|
|
348
|
-
{busy === 'approve' ? 'Saving…' : `Approve → ${cfg.transitions.approve}`}
|
|
349
|
-
</Button>
|
|
350
|
-
) : null}
|
|
351
|
-
{cfg.transitions.reject ? (
|
|
352
|
-
<Button
|
|
353
|
-
size="sm"
|
|
354
|
-
variant="outline"
|
|
355
|
-
onClick={reject}
|
|
356
|
-
disabled={busy === 'reject'}
|
|
357
|
-
className="flex-1 border-rose-300 text-rose-700 hover:bg-rose-50"
|
|
358
|
-
>
|
|
359
|
-
<X className="mr-1.5 h-4 w-4" />
|
|
360
|
-
{busy === 'reject' ? 'Saving…' : 'Reject'}
|
|
361
|
-
</Button>
|
|
362
|
-
) : null}
|
|
363
|
-
</div>
|
|
364
|
-
<p className="text-center text-[11px] text-muted-foreground">
|
|
365
|
-
j/k move · a approve · x reject · e edit · g/b verdict
|
|
366
|
-
</p>
|
|
367
368
|
</footer>
|
|
368
369
|
</>
|
|
369
370
|
)}
|
|
@@ -372,44 +373,58 @@ function ReviewDrawerInner({
|
|
|
372
373
|
);
|
|
373
374
|
}
|
|
374
375
|
|
|
375
|
-
const
|
|
376
|
-
good:
|
|
377
|
-
bad:
|
|
378
|
-
neutral: 'bg-amber-500 text-white',
|
|
379
|
-
};
|
|
380
|
-
const TONE_ICON: Record<VerdictOption['tone'], typeof ThumbsUp> = {
|
|
381
|
-
good: ThumbsUp,
|
|
382
|
-
bad: ThumbsDown,
|
|
376
|
+
const DECISION_ICON: Record<ReviewDecision['tone'], typeof Check> = {
|
|
377
|
+
good: Check,
|
|
378
|
+
bad: X,
|
|
383
379
|
neutral: PencilLine,
|
|
384
380
|
};
|
|
381
|
+
const DECISION_STYLE: Record<ReviewDecision['tone'], string> = {
|
|
382
|
+
good: 'bg-emerald-600 text-white hover:bg-emerald-700 border-emerald-600',
|
|
383
|
+
bad: 'border-rose-300 text-rose-700 hover:bg-rose-50',
|
|
384
|
+
neutral: 'border-amber-300 text-amber-700 hover:bg-amber-50',
|
|
385
|
+
};
|
|
385
386
|
|
|
386
|
-
function
|
|
387
|
-
|
|
387
|
+
function DecisionButton({
|
|
388
|
+
decision,
|
|
388
389
|
active,
|
|
389
390
|
busy,
|
|
390
391
|
onClick,
|
|
391
392
|
}: {
|
|
392
|
-
|
|
393
|
+
decision: ReviewDecision;
|
|
393
394
|
active: boolean;
|
|
394
395
|
busy: boolean;
|
|
395
396
|
onClick: () => void;
|
|
396
397
|
}) {
|
|
397
|
-
const Icon =
|
|
398
|
+
const Icon = DECISION_ICON[decision.tone];
|
|
398
399
|
return (
|
|
399
400
|
<button
|
|
400
401
|
type="button"
|
|
401
402
|
onClick={onClick}
|
|
402
403
|
disabled={busy}
|
|
404
|
+
title={`${decision.label} (${decision.shortcut})`}
|
|
403
405
|
className={cn(
|
|
404
|
-
'flex items-center
|
|
405
|
-
|
|
406
|
+
'flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md border px-3 py-2 text-sm font-medium transition disabled:opacity-60',
|
|
407
|
+
DECISION_STYLE[decision.tone],
|
|
408
|
+
active && 'ring-2 ring-offset-1 ring-current',
|
|
406
409
|
)}
|
|
407
410
|
>
|
|
408
|
-
<
|
|
411
|
+
<span className="flex items-center gap-1.5">
|
|
412
|
+
<Icon className="h-4 w-4" />
|
|
413
|
+
{busy ? 'Saving…' : decision.label}
|
|
414
|
+
</span>
|
|
415
|
+
<span className={cn('text-[10px] font-normal', decision.tone === 'good' ? 'text-white/80' : 'opacity-70')}>
|
|
416
|
+
{decisionHint(decision)}
|
|
417
|
+
</span>
|
|
409
418
|
</button>
|
|
410
419
|
);
|
|
411
420
|
}
|
|
412
421
|
|
|
422
|
+
/** The tiny "→ ready · good" outcome hint under a decision. */
|
|
423
|
+
function decisionHint(d: ReviewDecision): string {
|
|
424
|
+
const parts = [d.status ? `→ ${d.status}` : null, d.verdict].filter(Boolean);
|
|
425
|
+
return parts.join(' · ');
|
|
426
|
+
}
|
|
427
|
+
|
|
413
428
|
/** Source chips: host + optional tier badge, linked when a url is present. */
|
|
414
429
|
function ReviewSources({
|
|
415
430
|
data,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
deriveReviewTransitions,
|
|
3
3
|
resolveReviewConfig,
|
|
4
|
+
reviewDecisions,
|
|
4
5
|
pickStatusAttr,
|
|
5
6
|
shouldIgnoreShortcut,
|
|
6
7
|
nextReviewIndex,
|
|
@@ -100,6 +101,44 @@ describe('resolveReviewConfig', () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
104
|
+
describe('reviewDecisions', () => {
|
|
105
|
+
const topic = type([
|
|
106
|
+
enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
|
|
107
|
+
textAttr('team_verdict'),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
it('maps the three decisions to status + verdict pairs on the OGMC schema', () => {
|
|
111
|
+
const d = reviewDecisions(resolveReviewConfig(topic));
|
|
112
|
+
expect(d.map((x) => x.key)).toEqual(['approve', 'needs_work', 'reject']);
|
|
113
|
+
expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'ready', verdict: 'good', advance: true });
|
|
114
|
+
expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ status: 'suggested', verdict: 'edit', advance: false, promptNote: true });
|
|
115
|
+
expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: 'bad', advance: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('drops decisions whose status target does not resolve', () => {
|
|
119
|
+
// two-state enum with no rejection word → no Reject; approve resolves to the successor
|
|
120
|
+
const twoState = type([enumAttr('status', ['open', 'closed']), textAttr('team_verdict')]);
|
|
121
|
+
const keys = reviewDecisions(resolveReviewConfig(twoState)).map((x) => x.key);
|
|
122
|
+
expect(keys).toContain('approve');
|
|
123
|
+
expect(keys).not.toContain('reject');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('uses custom verdict vocab tones', () => {
|
|
127
|
+
const d = reviewDecisions(
|
|
128
|
+
resolveReviewConfig(topic, {
|
|
129
|
+
verdicts: [
|
|
130
|
+
{ value: 'yes', label: 'Yes', tone: 'good' },
|
|
131
|
+
{ value: 'no', label: 'No', tone: 'bad' },
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
expect(d.find((x) => x.key === 'approve')?.verdict).toBe('yes');
|
|
136
|
+
expect(d.find((x) => x.key === 'reject')?.verdict).toBe('no');
|
|
137
|
+
// no neutral verdict, but an entry stage exists → Needs work still offered (verdict null)
|
|
138
|
+
expect(d.find((x) => x.key === 'needs_work')).toMatchObject({ verdict: null });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
103
142
|
describe('shouldIgnoreShortcut', () => {
|
|
104
143
|
it('ignores while typing in a form control', () => {
|
|
105
144
|
expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true);
|
|
@@ -215,6 +215,88 @@ export function resolveReviewConfig(
|
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
217
|
|
|
218
|
+
// ---- the single review decision (Approve / Needs work / Reject) ----
|
|
219
|
+
//
|
|
220
|
+
// status and team_verdict are two fields with different consumers (status = the
|
|
221
|
+
// pipeline gate the writer keys off; team_verdict = the signal the re-rank agent
|
|
222
|
+
// turns INTO status). Their outcomes overlap, so the reviewer makes ONE decision
|
|
223
|
+
// and we write BOTH fields coherently — never two competing button rows.
|
|
224
|
+
|
|
225
|
+
export interface ReviewDecision {
|
|
226
|
+
/** Stable id ('approve' | 'needs_work' | 'reject'). */
|
|
227
|
+
key: string;
|
|
228
|
+
label: string;
|
|
229
|
+
/** Status to write (null → leave status untouched). */
|
|
230
|
+
status: string | null;
|
|
231
|
+
/** Verdict to write (null → leave verdict untouched). */
|
|
232
|
+
verdict: string | null;
|
|
233
|
+
tone: VerdictTone;
|
|
234
|
+
/** Auto-advance to the next record after this decision. */
|
|
235
|
+
advance: boolean;
|
|
236
|
+
/** Bare-letter shortcut. */
|
|
237
|
+
shortcut: string;
|
|
238
|
+
/** Open the note box on this decision (a rejection/hold wants a reason). */
|
|
239
|
+
promptNote: boolean;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The (up to) three review decisions derived from the resolved config:
|
|
244
|
+
* - Approve → the approve status + the "good" verdict, auto-advances
|
|
245
|
+
* - Needs work → back to the entry stage + the "edit"/neutral verdict, holds
|
|
246
|
+
* (so it's revisited) and prompts a note
|
|
247
|
+
* - Reject → the reject status + the "bad" verdict, auto-advances
|
|
248
|
+
* A decision only appears when its status target resolves (or, for Needs work,
|
|
249
|
+
* when there's an entry stage or an edit verdict) — so a two-state or non-workflow
|
|
250
|
+
* enum simply shows fewer buttons instead of guessing.
|
|
251
|
+
*/
|
|
252
|
+
export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
253
|
+
const verdictOf = (tone: VerdictTone): string | null =>
|
|
254
|
+
r.verdicts.find((v) => v.tone === tone)?.value ?? null;
|
|
255
|
+
const entry = r.statusChoices[0] ?? null;
|
|
256
|
+
const out: ReviewDecision[] = [];
|
|
257
|
+
|
|
258
|
+
if (r.transitions.approve) {
|
|
259
|
+
out.push({
|
|
260
|
+
key: 'approve',
|
|
261
|
+
label: 'Approve',
|
|
262
|
+
status: r.transitions.approve,
|
|
263
|
+
verdict: verdictOf('good'),
|
|
264
|
+
tone: 'good',
|
|
265
|
+
advance: true,
|
|
266
|
+
shortcut: 'a',
|
|
267
|
+
promptNote: false,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const editVerdict = verdictOf('neutral');
|
|
272
|
+
if (entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
273
|
+
out.push({
|
|
274
|
+
key: 'needs_work',
|
|
275
|
+
label: 'Needs work',
|
|
276
|
+
status: entry,
|
|
277
|
+
verdict: editVerdict,
|
|
278
|
+
tone: 'neutral',
|
|
279
|
+
advance: false,
|
|
280
|
+
shortcut: 'w',
|
|
281
|
+
promptNote: true,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (r.transitions.reject) {
|
|
286
|
+
out.push({
|
|
287
|
+
key: 'reject',
|
|
288
|
+
label: 'Reject',
|
|
289
|
+
status: r.transitions.reject,
|
|
290
|
+
verdict: verdictOf('bad'),
|
|
291
|
+
tone: 'bad',
|
|
292
|
+
advance: true,
|
|
293
|
+
shortcut: 'x',
|
|
294
|
+
promptNote: false,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
299
|
+
|
|
218
300
|
// ---- small read helpers used by the component (kept here so they're tested) ----
|
|
219
301
|
|
|
220
302
|
/** String read of an attr off a record's data blob ('' when missing). */
|