@startsimpli/ui 0.4.60 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.60",
3
+ "version": "0.4.62",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,495 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Review-first record drawer — the fast triage surface for a status-carrying
5
+ * entity type (bd startsim-768w.18.14 / .15).
6
+ *
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).
16
+ *
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.
20
+ */
21
+ import { useEffect, useMemo, useState, type ReactNode } from 'react';
22
+ import {
23
+ ChevronLeft,
24
+ ChevronRight,
25
+ Check,
26
+ X,
27
+ PencilLine,
28
+ ExternalLink,
29
+ ShieldCheck,
30
+ MessageSquarePlus,
31
+ } from 'lucide-react';
32
+
33
+ import { Button } from '../ui/button';
34
+ import { Textarea } from '../ui/textarea';
35
+ import {
36
+ Select,
37
+ SelectContent,
38
+ SelectItem,
39
+ SelectTrigger,
40
+ SelectValue,
41
+ } from '../ui/select';
42
+ import { notify } from '../toast/use-notify';
43
+ import { cn } from '../../lib/utils';
44
+ import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
45
+ import { readData, toCamelKey } from './data';
46
+ import { parseSources } from './drafts';
47
+ import {
48
+ resolveReviewConfig,
49
+ reviewDecisions,
50
+ shouldIgnoreShortcut,
51
+ fieldStr,
52
+ reviewHostOf,
53
+ type ReviewConfig,
54
+ type ReviewDecision,
55
+ } from './review';
56
+
57
+ export interface ReviewDrawerProps {
58
+ client: CollectionClient;
59
+ type: EntityTypeDef;
60
+ /** The ordered queue the drawer walks with prev/next (the current list/page). */
61
+ records: EntityRecord[];
62
+ /** The open record (null closes the drawer). */
63
+ record: EntityRecord | null;
64
+ onClose: () => void;
65
+ /** Move the open record to another (prev/next / auto-advance). */
66
+ onNavigate: (record: EntityRecord) => void;
67
+ /** Fired after any successful save so the parent can refetch/invalidate. */
68
+ onSaved?: () => void;
69
+ /** Field-map + transition overrides; defaults match the OGMC content schema. */
70
+ config?: ReviewConfig;
71
+ /** Map of source host → tier (1 = approved) for the source chips. Optional. */
72
+ sourceTierByHost?: Map<string, number>;
73
+ /** The app's deep field editor, shown behind "Edit fields". Optional. */
74
+ renderEditFields?: (ctx: {
75
+ record: EntityRecord;
76
+ type: EntityTypeDef;
77
+ /** Return to review mode without saving. */
78
+ back: () => void;
79
+ /** Refetch the parent + return to review mode. */
80
+ saved: () => void;
81
+ }) => ReactNode;
82
+ /** App-specific extra content under the record (e.g. a topic's drafts). Optional. */
83
+ renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
84
+ }
85
+
86
+ export function ReviewDrawer(props: ReviewDrawerProps) {
87
+ if (!props.record) return null;
88
+ return <ReviewDrawerInner key={props.record.id} {...props} record={props.record} />;
89
+ }
90
+
91
+ function ReviewDrawerInner({
92
+ client,
93
+ type,
94
+ records,
95
+ record,
96
+ onClose,
97
+ onNavigate,
98
+ onSaved,
99
+ config,
100
+ sourceTierByHost,
101
+ renderEditFields,
102
+ renderExtra,
103
+ }: ReviewDrawerProps & { record: EntityRecord }) {
104
+ const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
105
+ const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
106
+ const [mode, setMode] = useState<'review' | 'edit'>('review');
107
+ // Local optimistic copy so a click reflects instantly (the parent also refetches).
108
+ const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
109
+ const [busy, setBusy] = useState<string | null>(null);
110
+ const [noteOpen, setNoteOpen] = useState(false);
111
+ const [note, setNote] = useState(() => fieldStr(record.data, cfg.noteAttr));
112
+
113
+ const index = records.findIndex((r) => String(r.id) === String(record.id));
114
+ const prev = index > 0 ? records[index - 1] : null;
115
+ const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null;
116
+
117
+ const status = fieldStr(data, cfg.statusName);
118
+ const verdict = fieldStr(data, cfg.verdictAttr);
119
+ const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
120
+ const summary = fieldStr(data, cfg.summaryAttr);
121
+ const rank = fieldStr(data, cfg.rankAttr);
122
+
123
+ /** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */
124
+ async function patch(changes: Record<string, unknown>, key: string, advance = false) {
125
+ const nextData: Record<string, unknown> = { ...data };
126
+ for (const [k, v] of Object.entries(changes)) {
127
+ const camel = toCamelKey(k);
128
+ if (v === '' || v == null) {
129
+ delete nextData[camel];
130
+ delete nextData[k];
131
+ } else {
132
+ nextData[camel] = v;
133
+ if (camel !== k) delete nextData[k];
134
+ }
135
+ }
136
+ setData(nextData); // optimistic
137
+ setBusy(key);
138
+ try {
139
+ await client.updateEntity(record.id, { data: nextData });
140
+ onSaved?.();
141
+ if (advance && next) onNavigate(next);
142
+ } catch (err) {
143
+ setData({ ...record.data }); // roll back
144
+ notify.error(err instanceof Error ? err.message : 'Could not save.');
145
+ } finally {
146
+ setBusy(null);
147
+ }
148
+ }
149
+
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);
157
+ }
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);
163
+ }
164
+
165
+ async function saveNote() {
166
+ await patch({ [cfg.noteAttr]: note.trim() }, 'note');
167
+ setNoteOpen(false);
168
+ }
169
+
170
+ // Keyboard: walk the queue + one-key decide. Guarded so it never fires while typing.
171
+ useEffect(() => {
172
+ if (mode !== 'review') return;
173
+ function onKey(e: KeyboardEvent) {
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
+ }
181
+ switch (e.key) {
182
+ case 'j':
183
+ case 'ArrowDown':
184
+ if (next) { e.preventDefault(); onNavigate(next); }
185
+ break;
186
+ case 'k':
187
+ case 'ArrowUp':
188
+ if (prev) { e.preventDefault(); onNavigate(prev); }
189
+ break;
190
+ case 'e': e.preventDefault(); setMode('edit'); break;
191
+ case 'Escape': e.preventDefault(); onClose(); break;
192
+ }
193
+ }
194
+ window.addEventListener('keydown', onKey);
195
+ return () => window.removeEventListener('keydown', onKey);
196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
197
+ }, [mode, prev, next, decisions, data]);
198
+
199
+ const legend = [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ');
200
+
201
+ return (
202
+ <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
203
+ <div className="absolute inset-0 bg-black/30" onClick={onClose} aria-hidden />
204
+ <aside className="relative z-10 flex h-full w-full max-w-2xl flex-col overflow-hidden border-l border-border bg-card shadow-xl">
205
+ <header className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
206
+ <div className="flex min-w-0 items-center gap-3">
207
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">{type.label}</span>
208
+ {index >= 0 && records.length > 0 ? (
209
+ <span className="shrink-0 text-xs text-muted-foreground">
210
+ {index + 1} / {records.length}
211
+ </span>
212
+ ) : null}
213
+ </div>
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}
230
+ <button
231
+ onClick={() => onNavigate(prev!)}
232
+ disabled={!prev}
233
+ className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
234
+ aria-label="Previous"
235
+ title="Previous (k / ↑)"
236
+ >
237
+ <ChevronLeft className="h-4 w-4" />
238
+ </button>
239
+ <button
240
+ onClick={() => onNavigate(next!)}
241
+ disabled={!next}
242
+ className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
243
+ aria-label="Next"
244
+ title="Next (j / ↓)"
245
+ >
246
+ <ChevronRight className="h-4 w-4" />
247
+ </button>
248
+ <button
249
+ onClick={() => setMode((m) => (m === 'review' ? 'edit' : 'review'))}
250
+ className="ml-1 rounded border border-border px-2.5 py-1 text-xs hover:bg-muted"
251
+ title="Edit fields (e)"
252
+ >
253
+ {mode === 'review' ? 'Edit fields' : 'Review'}
254
+ </button>
255
+ <button
256
+ onClick={onClose}
257
+ className="rounded p-1 text-muted-foreground hover:bg-muted"
258
+ aria-label="Close"
259
+ >
260
+ <X className="h-4 w-4" />
261
+ </button>
262
+ </div>
263
+ </header>
264
+
265
+ {mode === 'edit' ? (
266
+ <div className="flex-1 overflow-y-auto px-4 py-4">
267
+ {renderEditFields ? (
268
+ renderEditFields({
269
+ record,
270
+ type,
271
+ back: () => setMode('review'),
272
+ saved: () => {
273
+ onSaved?.();
274
+ setMode('review');
275
+ },
276
+ })
277
+ ) : (
278
+ <p className="text-sm text-muted-foreground">No field editor is wired for this type.</p>
279
+ )}
280
+ </div>
281
+ ) : (
282
+ <>
283
+ <div className="flex-1 overflow-y-auto px-5 py-4">
284
+ <h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
285
+
286
+ <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
287
+ {rank ? (
288
+ <span className="rounded-full bg-primary-50 px-2 py-0.5 font-semibold text-primary-700">
289
+ rank {rank}
290
+ </span>
291
+ ) : null}
292
+ {cfg.metaAttrs.map((a) => {
293
+ const v = fieldStr(data, a);
294
+ if (!v) return null;
295
+ return (
296
+ <span key={a} className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
297
+ {v.replace(/_/g, ' ')}
298
+ </span>
299
+ );
300
+ })}
301
+ </div>
302
+
303
+ {summary ? <p className="mt-3 text-sm leading-relaxed text-foreground/80">{summary}</p> : null}
304
+
305
+ <ReviewSources data={data} sourceAttrs={cfg.sourceAttrs} tierByHost={sourceTierByHost} />
306
+
307
+ {renderExtra ? <div className="mt-6 border-t border-border pt-4">{renderExtra(record, type)}</div> : null}
308
+ </div>
309
+
310
+ <footer className="space-y-3 border-t border-border px-4 py-3">
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)}
320
+ />
321
+ ))}
322
+ </div>
323
+ ) : null}
324
+
325
+ <div className="flex items-center gap-2">
326
+ <button
327
+ onClick={() => setNoteOpen((o) => !o)}
328
+ className={cn(
329
+ 'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
330
+ note && 'text-primary-700',
331
+ )}
332
+ >
333
+ <MessageSquarePlus className="h-3.5 w-3.5" />
334
+ {note ? 'Note ✓' : 'Add note'}
335
+ </button>
336
+ <span className="ml-auto text-[11px] text-muted-foreground">{legend}</span>
337
+ </div>
338
+
339
+ {noteOpen ? (
340
+ <div className="space-y-2">
341
+ <Textarea
342
+ value={note}
343
+ onChange={(e) => setNote(e.target.value)}
344
+ rows={2}
345
+ placeholder="Why? — e.g. “good topic, angle too broad; needs a fresher source.”"
346
+ className="text-sm"
347
+ autoFocus
348
+ />
349
+ <div className="flex gap-2">
350
+ <Button size="sm" onClick={saveNote} disabled={busy === 'note'}>
351
+ {busy === 'note' ? 'Saving…' : 'Save note'}
352
+ </Button>
353
+ <Button
354
+ size="sm"
355
+ variant="outline"
356
+ onClick={() => {
357
+ setNote(fieldStr(data, cfg.noteAttr));
358
+ setNoteOpen(false);
359
+ }}
360
+ >
361
+ Cancel
362
+ </Button>
363
+ </div>
364
+ </div>
365
+ ) : note ? (
366
+ <p className="border-l-2 border-border pl-2 text-xs italic text-muted-foreground">{note}</p>
367
+ ) : null}
368
+ </footer>
369
+ </>
370
+ )}
371
+ </aside>
372
+ </div>
373
+ );
374
+ }
375
+
376
+ const DECISION_ICON: Record<ReviewDecision['tone'], typeof Check> = {
377
+ good: Check,
378
+ bad: X,
379
+ neutral: PencilLine,
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
+ };
386
+
387
+ function DecisionButton({
388
+ decision,
389
+ active,
390
+ busy,
391
+ onClick,
392
+ }: {
393
+ decision: ReviewDecision;
394
+ active: boolean;
395
+ busy: boolean;
396
+ onClick: () => void;
397
+ }) {
398
+ const Icon = DECISION_ICON[decision.tone];
399
+ return (
400
+ <button
401
+ type="button"
402
+ onClick={onClick}
403
+ disabled={busy}
404
+ title={`${decision.label} (${decision.shortcut})`}
405
+ className={cn(
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',
409
+ )}
410
+ >
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>
418
+ </button>
419
+ );
420
+ }
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
+
428
+ /** Source chips: host + optional tier badge, linked when a url is present. */
429
+ function ReviewSources({
430
+ data,
431
+ sourceAttrs,
432
+ tierByHost,
433
+ }: {
434
+ data: Record<string, unknown>;
435
+ sourceAttrs: string[];
436
+ tierByHost?: Map<string, number>;
437
+ }) {
438
+ const urls = useMemo(() => {
439
+ const out: string[] = [];
440
+ for (const attr of sourceAttrs) {
441
+ const raw = readData(data, attr);
442
+ if (raw == null || raw === '') continue;
443
+ if (attr === 'sources' || Array.isArray(raw)) {
444
+ for (const s of parseSources(raw)) {
445
+ const u = (s.url || '').trim();
446
+ if (u) out.push(u);
447
+ }
448
+ } else {
449
+ out.push(String(raw).trim());
450
+ }
451
+ }
452
+ return out.filter(Boolean);
453
+ }, [data, sourceAttrs]);
454
+
455
+ if (sourceAttrs.length === 0) return null;
456
+ if (urls.length === 0) {
457
+ return (
458
+ <p className="mt-3 text-xs font-medium text-amber-600">No sources — needs verification.</p>
459
+ );
460
+ }
461
+ return (
462
+ <div className="mt-3 flex flex-wrap items-center gap-1.5">
463
+ <span className="text-xs text-muted-foreground">Sources:</span>
464
+ {urls.map((u, i) => {
465
+ const host = reviewHostOf(u);
466
+ const tier = host ? tierByHost?.get(host) : undefined;
467
+ const href = /^https?:\/\//.test(u) ? u : host ? `https://${u}` : undefined;
468
+ const chip = (
469
+ <span className="inline-flex items-center gap-1 rounded-full border border-border bg-card px-2 py-0.5 text-xs text-foreground/80">
470
+ {tier === 1 ? <ShieldCheck className="h-3 w-3 text-emerald-600" /> : null}
471
+ <span className="max-w-[12rem] truncate">{host || u}</span>
472
+ {tier != null ? (
473
+ <span
474
+ className={cn(
475
+ 'rounded px-1 text-[10px] font-semibold',
476
+ tier === 1 ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground',
477
+ )}
478
+ >
479
+ T{tier}
480
+ </span>
481
+ ) : null}
482
+ {href ? <ExternalLink className="h-3 w-3 text-muted-foreground" /> : null}
483
+ </span>
484
+ );
485
+ return href ? (
486
+ <a key={i} href={href} target="_blank" rel="noreferrer" className="hover:opacity-80">
487
+ {chip}
488
+ </a>
489
+ ) : (
490
+ <span key={i}>{chip}</span>
491
+ );
492
+ })}
493
+ </div>
494
+ );
495
+ }
@@ -0,0 +1,181 @@
1
+ import {
2
+ deriveReviewTransitions,
3
+ resolveReviewConfig,
4
+ reviewDecisions,
5
+ pickStatusAttr,
6
+ shouldIgnoreShortcut,
7
+ nextReviewIndex,
8
+ reviewHostOf,
9
+ fieldStr,
10
+ DEFAULT_VERDICTS,
11
+ } from '../review';
12
+ import type { AttributeDef, EntityTypeDef } from '../types';
13
+
14
+ function enumAttr(name: string, choices: string[]): AttributeDef {
15
+ return { id: name, name, dataType: 'enum', required: false, config: { choices } };
16
+ }
17
+ function textAttr(name: string): AttributeDef {
18
+ return { id: name, name, dataType: 'text', required: false, config: {} };
19
+ }
20
+ function type(attrs: AttributeDef[]): EntityTypeDef {
21
+ return { id: 'topic', key: 'topic', label: 'Topic', attributes: attrs };
22
+ }
23
+
24
+ describe('deriveReviewTransitions', () => {
25
+ it('maps the canonical OGMC pipeline (suggested→ready→rejected→written)', () => {
26
+ const t = deriveReviewTransitions(['suggested', 'ready', 'rejected', 'written']);
27
+ expect(t.approve).toBe('ready');
28
+ expect(t.reject).toBe('rejected');
29
+ });
30
+
31
+ it('skips terminal (published-like) stages when choosing Approve', () => {
32
+ // No dedicated "ready" — approve should still avoid the terminal "written".
33
+ const t = deriveReviewTransitions(['new', 'rejected', 'written']);
34
+ expect(t.reject).toBe('rejected');
35
+ // only forward non-terminal, non-reject candidate is none → falls back to the
36
+ // first successor that isn't the reject stage → 'written' (best available).
37
+ expect(t.approve).toBe('written');
38
+ });
39
+
40
+ it('detects varied rejection words', () => {
41
+ expect(deriveReviewTransitions(['open', 'approved', 'declined']).reject).toBe('declined');
42
+ expect(deriveReviewTransitions(['inbox', 'live', 'archived']).reject).toBe('archived');
43
+ });
44
+
45
+ it('honours explicit overrides (including nulling a button off)', () => {
46
+ const t = deriveReviewTransitions(['a', 'b', 'c'], { approve: 'c', reject: null });
47
+ expect(t.approve).toBe('c');
48
+ expect(t.reject).toBeNull();
49
+ });
50
+
51
+ it('returns nulls for an empty choice set', () => {
52
+ expect(deriveReviewTransitions([])).toEqual({ approve: null, reject: null });
53
+ });
54
+ });
55
+
56
+ describe('pickStatusAttr', () => {
57
+ it('prefers an attr named "status"', () => {
58
+ const t = type([enumAttr('kind', ['a', 'b']), enumAttr('status', ['x', 'y'])]);
59
+ expect(pickStatusAttr(t)?.name).toBe('status');
60
+ });
61
+ it('falls back to the first enum with choices', () => {
62
+ const t = type([textAttr('title'), enumAttr('stage', ['x', 'y'])]);
63
+ expect(pickStatusAttr(t)?.name).toBe('stage');
64
+ });
65
+ it('returns null when no enum attr has choices', () => {
66
+ expect(pickStatusAttr(type([textAttr('title')]))).toBeNull();
67
+ });
68
+ });
69
+
70
+ describe('resolveReviewConfig', () => {
71
+ const topic = type([
72
+ textAttr('title'),
73
+ textAttr('angle'),
74
+ enumAttr('content_type', ['weekly_brief', 'lead_magnet', 'general']),
75
+ enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
76
+ textAttr('market'),
77
+ textAttr('team_verdict'),
78
+ textAttr('team_notes'),
79
+ ]);
80
+
81
+ it('resolves the OGMC defaults from the live schema', () => {
82
+ const r = resolveReviewConfig(topic);
83
+ expect(r.statusName).toBe('status');
84
+ expect(r.transitions).toEqual({ approve: 'ready', reject: 'rejected' });
85
+ expect(r.verdictAttr).toBe('team_verdict');
86
+ expect(r.noteAttr).toBe('team_notes');
87
+ expect(r.verdicts).toBe(DEFAULT_VERDICTS);
88
+ });
89
+
90
+ it('drops meta/source attrs the type does not declare', () => {
91
+ const r = resolveReviewConfig(topic);
92
+ // default meta ['content_type','market'] both declared; default sources none declared
93
+ expect(r.metaAttrs).toEqual(['content_type', 'market']);
94
+ expect(r.sourceAttrs).toEqual([]);
95
+ });
96
+
97
+ it('lets overrides win', () => {
98
+ const r = resolveReviewConfig(topic, { rejectStatus: 'written', verdictAttr: 'vote' });
99
+ expect(r.transitions.reject).toBe('written');
100
+ expect(r.verdictAttr).toBe('vote');
101
+ });
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
+
142
+ describe('shouldIgnoreShortcut', () => {
143
+ it('ignores while typing in a form control', () => {
144
+ expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true);
145
+ expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'TEXTAREA' } })).toBe(true);
146
+ expect(shouldIgnoreShortcut({ key: 'a', target: { isContentEditable: true } })).toBe(true);
147
+ });
148
+ it('ignores modified keys', () => {
149
+ expect(shouldIgnoreShortcut({ key: 'a', metaKey: true, target: { tagName: 'DIV' } })).toBe(true);
150
+ });
151
+ it('fires on a bare letter over non-input', () => {
152
+ expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'DIV' } })).toBe(false);
153
+ });
154
+ });
155
+
156
+ describe('nextReviewIndex', () => {
157
+ it('advances to the next item', () => {
158
+ expect(nextReviewIndex(['a', 'b', 'c'], 'a')).toBe(1);
159
+ expect(nextReviewIndex(['a', 'b', 'c'], 'b')).toBe(2);
160
+ });
161
+ it('steps back when acting on the last item', () => {
162
+ expect(nextReviewIndex(['a', 'b', 'c'], 'c')).toBe(1);
163
+ });
164
+ it('returns -1 when the list would empty', () => {
165
+ expect(nextReviewIndex(['a'], 'a')).toBe(-1);
166
+ expect(nextReviewIndex([], 'a')).toBe(-1);
167
+ });
168
+ });
169
+
170
+ describe('reviewHostOf / fieldStr', () => {
171
+ it('bare-hosts a url with or without scheme', () => {
172
+ expect(reviewHostOf('https://www.reuters.com/x')).toBe('reuters.com');
173
+ expect(reviewHostOf('gov.ae/notice')).toBe('gov.ae');
174
+ expect(reviewHostOf('not a url')).toBe('');
175
+ });
176
+ it('reads camel or snake keys off the data blob', () => {
177
+ expect(fieldStr({ teamVerdict: 'good' }, 'team_verdict')).toBe('good');
178
+ expect(fieldStr({ team_verdict: 'bad' }, 'team_verdict')).toBe('bad');
179
+ expect(fieldStr(undefined, 'x')).toBe('');
180
+ });
181
+ });
@@ -7,6 +7,23 @@
7
7
  */
8
8
  export { TopicReviewWorkspace, type TopicReviewWorkspaceProps } from './TopicReviewWorkspace';
9
9
  export { DraftReviewWorkspace, type DraftReviewWorkspaceProps } from './DraftReviewWorkspace';
10
+ export { ReviewDrawer, type ReviewDrawerProps } from './ReviewDrawer';
11
+ export {
12
+ resolveReviewConfig,
13
+ deriveReviewTransitions,
14
+ reviewDecisions,
15
+ type ReviewDecision,
16
+ pickStatusAttr,
17
+ shouldIgnoreShortcut,
18
+ nextReviewIndex,
19
+ choicesOf,
20
+ DEFAULT_VERDICTS,
21
+ type ReviewConfig,
22
+ type ResolvedReview,
23
+ type ReviewTransitions,
24
+ type VerdictOption,
25
+ type VerdictTone,
26
+ } from './review';
10
27
  export type { CollectionClient, EntityRecord, EntityTypeDef, AttributeDef, Paginated, DataType } from './types';
11
28
  export { readData, toCamelKey } from './data';
12
29
  export {
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Pure, framework-free logic for the review-first record drawer (ReviewDrawer).
3
+ *
4
+ * A "review" surface turns a status-carrying entity type into a fast triage loop:
5
+ * read the record, cast a verdict, approve/reject it (a status move), jot a note,
6
+ * and step to the next one — all without leaving the drawer. Everything that can be
7
+ * decided WITHOUT React lives here so it unit-tests in isolation and the component
8
+ * stays thin:
9
+ * - resolve the review "field map" from a declared schema (which attr is the
10
+ * title / summary / status / verdict / note / rank / sources), with smart
11
+ * defaults that match the OGMC content schema but are fully overridable;
12
+ * - derive the Approve / Reject status targets from the status enum's choices;
13
+ * - the bare-letter keyboard-shortcut guard (never fire while the user is typing).
14
+ *
15
+ * Nothing here is tenant-specific — the defaults are conventions, not requirements.
16
+ */
17
+ import type { AttributeDef, EntityTypeDef } from './types';
18
+ import { readData } from './data';
19
+
20
+ // ---- keyboard guard (lifted so the drawer + any review surface share it) ----
21
+
22
+ /** The bit of an event target we need in order to ask "is the user typing?". */
23
+ export interface TypingTarget {
24
+ tagName?: string;
25
+ isContentEditable?: boolean;
26
+ }
27
+
28
+ /** The bit of a KeyboardEvent a shortcut decision depends on. */
29
+ export interface ShortcutEventLike {
30
+ key: string;
31
+ metaKey?: boolean;
32
+ ctrlKey?: boolean;
33
+ altKey?: boolean;
34
+ target?: unknown;
35
+ }
36
+
37
+ const TYPING_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
38
+
39
+ /** True when the event target takes typed input (form control or contenteditable). */
40
+ export function isTypingTarget(target: unknown): boolean {
41
+ if (!target || typeof target !== 'object') return false;
42
+ const el = target as TypingTarget;
43
+ if (el.isContentEditable === true) return true;
44
+ return typeof el.tagName === 'string' && TYPING_TAGS.has(el.tagName.toUpperCase());
45
+ }
46
+
47
+ /**
48
+ * True when a bare-letter review shortcut must NOT fire. Any of cmd/ctrl/alt means
49
+ * the user is reaching for a browser/OS command; a form control / contenteditable
50
+ * means they're typing (a note, an edit) — firing "x" would silently reject.
51
+ */
52
+ export function shouldIgnoreShortcut(event: ShortcutEventLike): boolean {
53
+ if (event.metaKey || event.ctrlKey || event.altKey) return true;
54
+ return isTypingTarget(event.target);
55
+ }
56
+
57
+ // ---- verdict vocabulary ----
58
+
59
+ export type VerdictTone = 'good' | 'bad' | 'neutral';
60
+
61
+ export interface VerdictOption {
62
+ value: string;
63
+ label: string;
64
+ tone: VerdictTone;
65
+ }
66
+
67
+ /** The default team-verdict vocabulary (👍 good / 👎 bad / ✎ needs-edit). */
68
+ export const DEFAULT_VERDICTS: VerdictOption[] = [
69
+ { value: 'good', label: 'Good', tone: 'good' },
70
+ { value: 'bad', label: 'Bad', tone: 'bad' },
71
+ { value: 'edit', label: 'Edit', tone: 'neutral' },
72
+ ];
73
+
74
+ // ---- status transitions (Approve / Reject) ----
75
+
76
+ const REJECT_RE = /reject|declin|archiv|spam|discard|dismiss|kill/i;
77
+ const TERMINAL_RE = /written|publish|post|done|complete|sent|live|shipped|closed/i;
78
+
79
+ /** The declared enum choices of an attribute (coerced to strings), or []. */
80
+ export function choicesOf(attr: AttributeDef | null | undefined): string[] {
81
+ const c = attr?.config?.choices;
82
+ return Array.isArray(c) ? c.map((x) => String(x)) : [];
83
+ }
84
+
85
+ /**
86
+ * The attribute whose enum choices drive the status pipeline: prefer one named
87
+ * exactly `preferred` (default "status"), else the first enum attr with choices.
88
+ * null when the type declares none (the caller falls back / hides the actions).
89
+ */
90
+ export function pickStatusAttr(
91
+ type: EntityTypeDef | undefined | null,
92
+ preferred = 'status',
93
+ ): AttributeDef | null {
94
+ const enums = (type?.attributes ?? []).filter(
95
+ (a) => a.dataType === 'enum' && choicesOf(a).length > 0,
96
+ );
97
+ if (enums.length === 0) return null;
98
+ return enums.find((a) => a.name === preferred) ?? enums[0];
99
+ }
100
+
101
+ export interface ReviewTransitions {
102
+ /** Status value the Approve action moves a record to (null → no Approve button). */
103
+ approve: string | null;
104
+ /** Status value the Reject action moves a record to (null → no Reject button). */
105
+ reject: string | null;
106
+ }
107
+
108
+ /**
109
+ * Derive the Approve / Reject status targets from a status enum's choices.
110
+ *
111
+ * Convention (matches `suggested → ready → rejected → written`):
112
+ * - REJECT = the first choice that reads like a rejection (rejected/declined/…);
113
+ * - APPROVE = the first "forward" choice: not the entry (first) stage, not the
114
+ * reject stage, and not a terminal/published stage — i.e. the "accepted, ready
115
+ * to act" stage. Falls back to the entry stage's immediate successor.
116
+ * Explicit overrides always win. Returns nulls when nothing sensible resolves, so
117
+ * a two-state or non-workflow enum simply shows fewer buttons rather than guessing.
118
+ */
119
+ export function deriveReviewTransitions(
120
+ choices: string[],
121
+ override?: { approve?: string | null; reject?: string | null },
122
+ ): ReviewTransitions {
123
+ const reject =
124
+ override?.reject !== undefined
125
+ ? override.reject
126
+ : choices.find((c) => REJECT_RE.test(c)) ?? null;
127
+
128
+ let approve: string | null;
129
+ if (override?.approve !== undefined) {
130
+ approve = override.approve;
131
+ } else {
132
+ const entry = choices[0];
133
+ approve =
134
+ choices.find(
135
+ (c, i) => i > 0 && c !== reject && !TERMINAL_RE.test(c) && c !== entry,
136
+ ) ??
137
+ // nothing "forward & non-terminal" → the successor of the entry stage.
138
+ choices.find((c, i) => i > 0 && c !== reject) ??
139
+ null;
140
+ }
141
+ return { approve, reject };
142
+ }
143
+
144
+ // ---- resolved review field map ----
145
+
146
+ /** Consumer-facing overrides for the review field map (all optional). */
147
+ export interface ReviewConfig {
148
+ /** Enum attr that drives the status pipeline (default: "status", else first enum). */
149
+ statusAttr?: string;
150
+ /** Explicit Approve target status (default: derived from the choices). */
151
+ approveStatus?: string | null;
152
+ /** Explicit Reject target status (default: derived from the choices). */
153
+ rejectStatus?: string | null;
154
+ /** Attr the verdict buttons write (default: "team_verdict"). */
155
+ verdictAttr?: string;
156
+ /** Verdict vocabulary (default: good / bad / edit). */
157
+ verdicts?: VerdictOption[];
158
+ /** Attr the quick-note writes (default: "team_notes"). */
159
+ noteAttr?: string;
160
+ /** Attr used as the readable heading (default: "title", else record.name). */
161
+ titleAttr?: string;
162
+ /** Attr used as the one-line summary under the title (default: "angle"). */
163
+ summaryAttr?: string;
164
+ /** Numeric attr shown as a rank chip (default: "ai_rank"). */
165
+ rankAttr?: string;
166
+ /** Short attrs shown as meta chips (default: ["content_type", "market"]). */
167
+ metaAttrs?: string[];
168
+ /** Attrs holding source urls/lines (default: source_1..3 + "sources"). */
169
+ sourceAttrs?: string[];
170
+ }
171
+
172
+ export interface ResolvedReview {
173
+ statusAttr: AttributeDef | null;
174
+ statusName: string;
175
+ statusChoices: string[];
176
+ transitions: ReviewTransitions;
177
+ verdictAttr: string;
178
+ verdicts: VerdictOption[];
179
+ noteAttr: string;
180
+ titleAttr: string;
181
+ summaryAttr: string;
182
+ rankAttr: string;
183
+ metaAttrs: string[];
184
+ sourceAttrs: string[];
185
+ }
186
+
187
+ const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
188
+ const DEFAULT_META_ATTRS = ['content_type', 'market'];
189
+
190
+ /** Resolve the full review field map from a type's schema + optional overrides. */
191
+ export function resolveReviewConfig(
192
+ type: EntityTypeDef | undefined | null,
193
+ config: ReviewConfig = {},
194
+ ): ResolvedReview {
195
+ const statusAttr = pickStatusAttr(type, config.statusAttr ?? 'status');
196
+ const statusChoices = choicesOf(statusAttr);
197
+ const declared = new Set((type?.attributes ?? []).map((a) => a.name));
198
+ return {
199
+ statusAttr,
200
+ statusName: statusAttr?.name ?? config.statusAttr ?? 'status',
201
+ statusChoices,
202
+ transitions: deriveReviewTransitions(statusChoices, {
203
+ approve: config.approveStatus,
204
+ reject: config.rejectStatus,
205
+ }),
206
+ verdictAttr: config.verdictAttr ?? 'team_verdict',
207
+ verdicts: config.verdicts ?? DEFAULT_VERDICTS,
208
+ noteAttr: config.noteAttr ?? 'team_notes',
209
+ titleAttr: config.titleAttr ?? 'title',
210
+ summaryAttr: config.summaryAttr ?? 'angle',
211
+ rankAttr: config.rankAttr ?? 'ai_rank',
212
+ metaAttrs: (config.metaAttrs ?? DEFAULT_META_ATTRS).filter((a) => declared.has(a)),
213
+ // Keep source attrs the caller asked for; default to the ones this type declares.
214
+ sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
215
+ };
216
+ }
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
+
300
+ // ---- small read helpers used by the component (kept here so they're tested) ----
301
+
302
+ /** String read of an attr off a record's data blob ('' when missing). */
303
+ export function fieldStr(data: Record<string, unknown> | undefined, name: string): string {
304
+ const v = readData(data, name);
305
+ return v == null ? '' : String(v);
306
+ }
307
+
308
+ /** Normalise a url to its bare host (lowercased, `www.` stripped). '' if unparseable. */
309
+ export function reviewHostOf(url: string): string {
310
+ try {
311
+ return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname
312
+ .toLowerCase()
313
+ .replace(/^www\./, '');
314
+ } catch {
315
+ return '';
316
+ }
317
+ }
318
+
319
+ /**
320
+ * The index to move to after acting on `current` within `ids`. Returns the next
321
+ * item, or the previous when acting on the last, or -1 when the list empties — so
322
+ * an Approve/Reject can auto-advance the reviewer to fresh work without a dead end.
323
+ */
324
+ export function nextReviewIndex(ids: string[], currentId: string): number {
325
+ const i = ids.indexOf(currentId);
326
+ if (i === -1) return ids.length > 0 ? 0 : -1;
327
+ if (i + 1 < ids.length) return i + 1;
328
+ if (i - 1 >= 0) return i - 1;
329
+ return -1;
330
+ }