@startsimpli/ui 0.4.60 → 0.4.61
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
|
@@ -0,0 +1,480 @@
|
|
|
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).
|
|
6
|
+
*
|
|
7
|
+
* Opens READ-OPTIMISED, not as an edit form: the record's title / summary / sources
|
|
8
|
+
* up top, then a sticky action bar that ACTS IN ONE CLICK and SAVES IMMEDIATELY —
|
|
9
|
+
* cast a verdict (👍/👎/✎), Approve (→ ready) or Reject (→ rejected) which advances
|
|
10
|
+
* to the next record without closing, and jot a quick note. ↑/↓ (or j/k) walk the
|
|
11
|
+
* queue; a / x / e / g / b are bare-letter shortcuts (guarded while typing). Deep
|
|
12
|
+
* field-editing stays available behind "Edit fields" (the app injects its own form
|
|
13
|
+
* via renderEditFields), so nothing is lost — it's just no longer the default.
|
|
14
|
+
*
|
|
15
|
+
* Generic + client-injected: the consumer passes a CollectionClient (its authed
|
|
16
|
+
* tenant API), the ordered queue (for prev/next), and an optional ReviewConfig /
|
|
17
|
+
* render-props. Defaults match the OGMC content schema but everything is a prop, so
|
|
18
|
+
* nothing here is tenant-specific.
|
|
19
|
+
*/
|
|
20
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
21
|
+
import {
|
|
22
|
+
ChevronLeft,
|
|
23
|
+
ChevronRight,
|
|
24
|
+
ThumbsUp,
|
|
25
|
+
ThumbsDown,
|
|
26
|
+
PencilLine,
|
|
27
|
+
Check,
|
|
28
|
+
X,
|
|
29
|
+
ExternalLink,
|
|
30
|
+
ShieldCheck,
|
|
31
|
+
MessageSquarePlus,
|
|
32
|
+
} from 'lucide-react';
|
|
33
|
+
|
|
34
|
+
import { Button } from '../ui/button';
|
|
35
|
+
import { Textarea } from '../ui/textarea';
|
|
36
|
+
import { notify } from '../toast/use-notify';
|
|
37
|
+
import { cn } from '../../lib/utils';
|
|
38
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
39
|
+
import { readData, toCamelKey } from './data';
|
|
40
|
+
import { parseSources } from './drafts';
|
|
41
|
+
import {
|
|
42
|
+
resolveReviewConfig,
|
|
43
|
+
shouldIgnoreShortcut,
|
|
44
|
+
fieldStr,
|
|
45
|
+
reviewHostOf,
|
|
46
|
+
type ReviewConfig,
|
|
47
|
+
type VerdictOption,
|
|
48
|
+
} from './review';
|
|
49
|
+
|
|
50
|
+
export interface ReviewDrawerProps {
|
|
51
|
+
client: CollectionClient;
|
|
52
|
+
type: EntityTypeDef;
|
|
53
|
+
/** The ordered queue the drawer walks with prev/next (the current list/page). */
|
|
54
|
+
records: EntityRecord[];
|
|
55
|
+
/** The open record (null closes the drawer). */
|
|
56
|
+
record: EntityRecord | null;
|
|
57
|
+
onClose: () => void;
|
|
58
|
+
/** Move the open record to another (prev/next / auto-advance). */
|
|
59
|
+
onNavigate: (record: EntityRecord) => void;
|
|
60
|
+
/** Fired after any successful save so the parent can refetch/invalidate. */
|
|
61
|
+
onSaved?: () => void;
|
|
62
|
+
/** Field-map + transition overrides; defaults match the OGMC content schema. */
|
|
63
|
+
config?: ReviewConfig;
|
|
64
|
+
/** Map of source host → tier (1 = approved) for the source chips. Optional. */
|
|
65
|
+
sourceTierByHost?: Map<string, number>;
|
|
66
|
+
/** The app's deep field editor, shown behind "Edit fields". Optional. */
|
|
67
|
+
renderEditFields?: (ctx: {
|
|
68
|
+
record: EntityRecord;
|
|
69
|
+
type: EntityTypeDef;
|
|
70
|
+
/** Return to review mode without saving. */
|
|
71
|
+
back: () => void;
|
|
72
|
+
/** Refetch the parent + return to review mode. */
|
|
73
|
+
saved: () => void;
|
|
74
|
+
}) => ReactNode;
|
|
75
|
+
/** App-specific extra content under the record (e.g. a topic's drafts). Optional. */
|
|
76
|
+
renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function ReviewDrawer(props: ReviewDrawerProps) {
|
|
80
|
+
if (!props.record) return null;
|
|
81
|
+
return <ReviewDrawerInner key={props.record.id} {...props} record={props.record} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ReviewDrawerInner({
|
|
85
|
+
client,
|
|
86
|
+
type,
|
|
87
|
+
records,
|
|
88
|
+
record,
|
|
89
|
+
onClose,
|
|
90
|
+
onNavigate,
|
|
91
|
+
onSaved,
|
|
92
|
+
config,
|
|
93
|
+
sourceTierByHost,
|
|
94
|
+
renderEditFields,
|
|
95
|
+
renderExtra,
|
|
96
|
+
}: ReviewDrawerProps & { record: EntityRecord }) {
|
|
97
|
+
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
98
|
+
const [mode, setMode] = useState<'review' | 'edit'>('review');
|
|
99
|
+
// Local optimistic copy so a click reflects instantly (the parent also refetches).
|
|
100
|
+
const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
|
|
101
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
102
|
+
const [noteOpen, setNoteOpen] = useState(false);
|
|
103
|
+
const [note, setNote] = useState(() => fieldStr(record.data, cfg.noteAttr));
|
|
104
|
+
|
|
105
|
+
const index = records.findIndex((r) => String(r.id) === String(record.id));
|
|
106
|
+
const prev = index > 0 ? records[index - 1] : null;
|
|
107
|
+
const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null;
|
|
108
|
+
|
|
109
|
+
const status = fieldStr(data, cfg.statusName);
|
|
110
|
+
const verdict = fieldStr(data, cfg.verdictAttr);
|
|
111
|
+
const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
|
|
112
|
+
const summary = fieldStr(data, cfg.summaryAttr);
|
|
113
|
+
const rank = fieldStr(data, cfg.rankAttr);
|
|
114
|
+
|
|
115
|
+
/** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */
|
|
116
|
+
async function patch(changes: Record<string, unknown>, key: string, advance = false) {
|
|
117
|
+
const nextData: Record<string, unknown> = { ...data };
|
|
118
|
+
for (const [k, v] of Object.entries(changes)) {
|
|
119
|
+
const camel = toCamelKey(k);
|
|
120
|
+
if (v === '' || v == null) {
|
|
121
|
+
delete nextData[camel];
|
|
122
|
+
delete nextData[k];
|
|
123
|
+
} else {
|
|
124
|
+
nextData[camel] = v;
|
|
125
|
+
if (camel !== k) delete nextData[k];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
setData(nextData); // optimistic
|
|
129
|
+
setBusy(key);
|
|
130
|
+
try {
|
|
131
|
+
await client.updateEntity(record.id, { data: nextData });
|
|
132
|
+
onSaved?.();
|
|
133
|
+
if (advance && next) onNavigate(next);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
setData({ ...record.data }); // roll back
|
|
136
|
+
notify.error(err instanceof Error ? err.message : 'Could not save.');
|
|
137
|
+
} finally {
|
|
138
|
+
setBusy(null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function setVerdict(value: string) {
|
|
143
|
+
void patch({ [cfg.verdictAttr]: verdict === value ? '' : value }, `verdict:${value}`);
|
|
144
|
+
}
|
|
145
|
+
function approve() {
|
|
146
|
+
if (cfg.transitions.approve) void patch({ [cfg.statusName]: cfg.transitions.approve }, 'approve', true);
|
|
147
|
+
}
|
|
148
|
+
function reject() {
|
|
149
|
+
if (cfg.transitions.reject) void patch({ [cfg.statusName]: cfg.transitions.reject }, 'reject', true);
|
|
150
|
+
}
|
|
151
|
+
async function saveNote() {
|
|
152
|
+
await patch({ [cfg.noteAttr]: note.trim() }, 'note');
|
|
153
|
+
setNoteOpen(false);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Keyboard: walk the queue + one-key act. Guarded so it never fires while typing.
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (mode !== 'review') return;
|
|
159
|
+
function onKey(e: KeyboardEvent) {
|
|
160
|
+
if (shouldIgnoreShortcut(e)) return;
|
|
161
|
+
switch (e.key) {
|
|
162
|
+
case 'j':
|
|
163
|
+
case 'ArrowDown':
|
|
164
|
+
if (next) { e.preventDefault(); onNavigate(next); }
|
|
165
|
+
break;
|
|
166
|
+
case 'k':
|
|
167
|
+
case 'ArrowUp':
|
|
168
|
+
if (prev) { e.preventDefault(); onNavigate(prev); }
|
|
169
|
+
break;
|
|
170
|
+
case 'a': e.preventDefault(); approve(); break;
|
|
171
|
+
case 'x': e.preventDefault(); reject(); break;
|
|
172
|
+
case 'e': e.preventDefault(); setMode('edit'); break;
|
|
173
|
+
case 'g': e.preventDefault(); setVerdict('good'); break;
|
|
174
|
+
case 'b': e.preventDefault(); setVerdict('bad'); break;
|
|
175
|
+
case 'Escape': e.preventDefault(); onClose(); break;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
window.addEventListener('keydown', onKey);
|
|
179
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
180
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
181
|
+
}, [mode, prev, next, verdict, status, data]);
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
|
|
185
|
+
<div className="absolute inset-0 bg-black/30" onClick={onClose} aria-hidden />
|
|
186
|
+
<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">
|
|
187
|
+
<header className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
|
|
188
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
189
|
+
<span className="text-xs uppercase tracking-wide text-muted-foreground">{type.label}</span>
|
|
190
|
+
{index >= 0 && records.length > 0 ? (
|
|
191
|
+
<span className="shrink-0 text-xs text-muted-foreground">
|
|
192
|
+
{index + 1} / {records.length}
|
|
193
|
+
</span>
|
|
194
|
+
) : null}
|
|
195
|
+
</div>
|
|
196
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
197
|
+
<button
|
|
198
|
+
onClick={() => onNavigate(prev!)}
|
|
199
|
+
disabled={!prev}
|
|
200
|
+
className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
|
201
|
+
aria-label="Previous"
|
|
202
|
+
title="Previous (k / ↑)"
|
|
203
|
+
>
|
|
204
|
+
<ChevronLeft className="h-4 w-4" />
|
|
205
|
+
</button>
|
|
206
|
+
<button
|
|
207
|
+
onClick={() => onNavigate(next!)}
|
|
208
|
+
disabled={!next}
|
|
209
|
+
className="rounded p-1 text-muted-foreground hover:bg-muted disabled:opacity-30"
|
|
210
|
+
aria-label="Next"
|
|
211
|
+
title="Next (j / ↓)"
|
|
212
|
+
>
|
|
213
|
+
<ChevronRight className="h-4 w-4" />
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
onClick={() => setMode((m) => (m === 'review' ? 'edit' : 'review'))}
|
|
217
|
+
className="ml-1 rounded border border-border px-2.5 py-1 text-xs hover:bg-muted"
|
|
218
|
+
title="Edit fields (e)"
|
|
219
|
+
>
|
|
220
|
+
{mode === 'review' ? 'Edit fields' : 'Review'}
|
|
221
|
+
</button>
|
|
222
|
+
<button
|
|
223
|
+
onClick={onClose}
|
|
224
|
+
className="rounded p-1 text-muted-foreground hover:bg-muted"
|
|
225
|
+
aria-label="Close"
|
|
226
|
+
>
|
|
227
|
+
<X className="h-4 w-4" />
|
|
228
|
+
</button>
|
|
229
|
+
</div>
|
|
230
|
+
</header>
|
|
231
|
+
|
|
232
|
+
{mode === 'edit' ? (
|
|
233
|
+
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
234
|
+
{renderEditFields ? (
|
|
235
|
+
renderEditFields({
|
|
236
|
+
record,
|
|
237
|
+
type,
|
|
238
|
+
back: () => setMode('review'),
|
|
239
|
+
saved: () => {
|
|
240
|
+
onSaved?.();
|
|
241
|
+
setMode('review');
|
|
242
|
+
},
|
|
243
|
+
})
|
|
244
|
+
) : (
|
|
245
|
+
<p className="text-sm text-muted-foreground">No field editor is wired for this type.</p>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
) : (
|
|
249
|
+
<>
|
|
250
|
+
<div className="flex-1 overflow-y-auto px-5 py-4">
|
|
251
|
+
<h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
|
|
252
|
+
|
|
253
|
+
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
|
|
254
|
+
{rank ? (
|
|
255
|
+
<span className="rounded-full bg-primary-50 px-2 py-0.5 font-semibold text-primary-700">
|
|
256
|
+
rank {rank}
|
|
257
|
+
</span>
|
|
258
|
+
) : 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
|
+
{cfg.metaAttrs.map((a) => {
|
|
265
|
+
const v = fieldStr(data, a);
|
|
266
|
+
if (!v) return null;
|
|
267
|
+
return (
|
|
268
|
+
<span key={a} className="rounded-full bg-muted px-2 py-0.5 text-muted-foreground">
|
|
269
|
+
{v.replace(/_/g, ' ')}
|
|
270
|
+
</span>
|
|
271
|
+
);
|
|
272
|
+
})}
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{summary ? <p className="mt-3 text-sm leading-relaxed text-foreground/80">{summary}</p> : null}
|
|
276
|
+
|
|
277
|
+
<ReviewSources data={data} sourceAttrs={cfg.sourceAttrs} tierByHost={sourceTierByHost} />
|
|
278
|
+
|
|
279
|
+
{renderExtra ? <div className="mt-6 border-t border-border pt-4">{renderExtra(record, type)}</div> : null}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
<footer className="space-y-3 border-t border-border px-4 py-3">
|
|
283
|
+
{/* Verdict */}
|
|
284
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
285
|
+
<span className="text-xs font-medium text-muted-foreground">Verdict</span>
|
|
286
|
+
<div className="flex overflow-hidden rounded-md border border-border">
|
|
287
|
+
{cfg.verdicts.map((v) => (
|
|
288
|
+
<VerdictButton
|
|
289
|
+
key={v.value}
|
|
290
|
+
option={v}
|
|
291
|
+
active={verdict === v.value}
|
|
292
|
+
busy={busy === `verdict:${v.value}`}
|
|
293
|
+
onClick={() => setVerdict(v.value)}
|
|
294
|
+
/>
|
|
295
|
+
))}
|
|
296
|
+
</div>
|
|
297
|
+
<button
|
|
298
|
+
onClick={() => setNoteOpen((o) => !o)}
|
|
299
|
+
className={cn(
|
|
300
|
+
'ml-auto inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
|
|
301
|
+
note && 'text-primary-700',
|
|
302
|
+
)}
|
|
303
|
+
>
|
|
304
|
+
<MessageSquarePlus className="h-3.5 w-3.5" />
|
|
305
|
+
{note ? 'Note ✓' : 'Note'}
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
|
|
309
|
+
{noteOpen ? (
|
|
310
|
+
<div className="space-y-2">
|
|
311
|
+
<Textarea
|
|
312
|
+
value={note}
|
|
313
|
+
onChange={(e) => setNote(e.target.value)}
|
|
314
|
+
rows={2}
|
|
315
|
+
placeholder="Quick note for the AI — e.g. “good topic, angle too broad; needs a fresher source.”"
|
|
316
|
+
className="text-sm"
|
|
317
|
+
/>
|
|
318
|
+
<div className="flex gap-2">
|
|
319
|
+
<Button size="sm" onClick={saveNote} disabled={busy === 'note'}>
|
|
320
|
+
{busy === 'note' ? 'Saving…' : 'Save note'}
|
|
321
|
+
</Button>
|
|
322
|
+
<Button
|
|
323
|
+
size="sm"
|
|
324
|
+
variant="outline"
|
|
325
|
+
onClick={() => {
|
|
326
|
+
setNote(fieldStr(data, cfg.noteAttr));
|
|
327
|
+
setNoteOpen(false);
|
|
328
|
+
}}
|
|
329
|
+
>
|
|
330
|
+
Cancel
|
|
331
|
+
</Button>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
) : note ? (
|
|
335
|
+
<p className="border-l-2 border-border pl-2 text-xs italic text-muted-foreground">{note}</p>
|
|
336
|
+
) : 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
|
+
</footer>
|
|
368
|
+
</>
|
|
369
|
+
)}
|
|
370
|
+
</aside>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const TONE_ON: Record<VerdictOption['tone'], string> = {
|
|
376
|
+
good: 'bg-emerald-600 text-white',
|
|
377
|
+
bad: 'bg-rose-600 text-white',
|
|
378
|
+
neutral: 'bg-amber-500 text-white',
|
|
379
|
+
};
|
|
380
|
+
const TONE_ICON: Record<VerdictOption['tone'], typeof ThumbsUp> = {
|
|
381
|
+
good: ThumbsUp,
|
|
382
|
+
bad: ThumbsDown,
|
|
383
|
+
neutral: PencilLine,
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
function VerdictButton({
|
|
387
|
+
option,
|
|
388
|
+
active,
|
|
389
|
+
busy,
|
|
390
|
+
onClick,
|
|
391
|
+
}: {
|
|
392
|
+
option: VerdictOption;
|
|
393
|
+
active: boolean;
|
|
394
|
+
busy: boolean;
|
|
395
|
+
onClick: () => void;
|
|
396
|
+
}) {
|
|
397
|
+
const Icon = TONE_ICON[option.tone];
|
|
398
|
+
return (
|
|
399
|
+
<button
|
|
400
|
+
type="button"
|
|
401
|
+
onClick={onClick}
|
|
402
|
+
disabled={busy}
|
|
403
|
+
className={cn(
|
|
404
|
+
'flex items-center gap-1 px-2.5 py-1 text-xs font-medium transition disabled:opacity-60',
|
|
405
|
+
active ? TONE_ON[option.tone] : 'bg-card text-muted-foreground hover:bg-muted',
|
|
406
|
+
)}
|
|
407
|
+
>
|
|
408
|
+
<Icon className="h-3.5 w-3.5" /> {option.label}
|
|
409
|
+
</button>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Source chips: host + optional tier badge, linked when a url is present. */
|
|
414
|
+
function ReviewSources({
|
|
415
|
+
data,
|
|
416
|
+
sourceAttrs,
|
|
417
|
+
tierByHost,
|
|
418
|
+
}: {
|
|
419
|
+
data: Record<string, unknown>;
|
|
420
|
+
sourceAttrs: string[];
|
|
421
|
+
tierByHost?: Map<string, number>;
|
|
422
|
+
}) {
|
|
423
|
+
const urls = useMemo(() => {
|
|
424
|
+
const out: string[] = [];
|
|
425
|
+
for (const attr of sourceAttrs) {
|
|
426
|
+
const raw = readData(data, attr);
|
|
427
|
+
if (raw == null || raw === '') continue;
|
|
428
|
+
if (attr === 'sources' || Array.isArray(raw)) {
|
|
429
|
+
for (const s of parseSources(raw)) {
|
|
430
|
+
const u = (s.url || '').trim();
|
|
431
|
+
if (u) out.push(u);
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
out.push(String(raw).trim());
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return out.filter(Boolean);
|
|
438
|
+
}, [data, sourceAttrs]);
|
|
439
|
+
|
|
440
|
+
if (sourceAttrs.length === 0) return null;
|
|
441
|
+
if (urls.length === 0) {
|
|
442
|
+
return (
|
|
443
|
+
<p className="mt-3 text-xs font-medium text-amber-600">No sources — needs verification.</p>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
return (
|
|
447
|
+
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
|
448
|
+
<span className="text-xs text-muted-foreground">Sources:</span>
|
|
449
|
+
{urls.map((u, i) => {
|
|
450
|
+
const host = reviewHostOf(u);
|
|
451
|
+
const tier = host ? tierByHost?.get(host) : undefined;
|
|
452
|
+
const href = /^https?:\/\//.test(u) ? u : host ? `https://${u}` : undefined;
|
|
453
|
+
const chip = (
|
|
454
|
+
<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">
|
|
455
|
+
{tier === 1 ? <ShieldCheck className="h-3 w-3 text-emerald-600" /> : null}
|
|
456
|
+
<span className="max-w-[12rem] truncate">{host || u}</span>
|
|
457
|
+
{tier != null ? (
|
|
458
|
+
<span
|
|
459
|
+
className={cn(
|
|
460
|
+
'rounded px-1 text-[10px] font-semibold',
|
|
461
|
+
tier === 1 ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground',
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
T{tier}
|
|
465
|
+
</span>
|
|
466
|
+
) : null}
|
|
467
|
+
{href ? <ExternalLink className="h-3 w-3 text-muted-foreground" /> : null}
|
|
468
|
+
</span>
|
|
469
|
+
);
|
|
470
|
+
return href ? (
|
|
471
|
+
<a key={i} href={href} target="_blank" rel="noreferrer" className="hover:opacity-80">
|
|
472
|
+
{chip}
|
|
473
|
+
</a>
|
|
474
|
+
) : (
|
|
475
|
+
<span key={i}>{chip}</span>
|
|
476
|
+
);
|
|
477
|
+
})}
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveReviewTransitions,
|
|
3
|
+
resolveReviewConfig,
|
|
4
|
+
pickStatusAttr,
|
|
5
|
+
shouldIgnoreShortcut,
|
|
6
|
+
nextReviewIndex,
|
|
7
|
+
reviewHostOf,
|
|
8
|
+
fieldStr,
|
|
9
|
+
DEFAULT_VERDICTS,
|
|
10
|
+
} from '../review';
|
|
11
|
+
import type { AttributeDef, EntityTypeDef } from '../types';
|
|
12
|
+
|
|
13
|
+
function enumAttr(name: string, choices: string[]): AttributeDef {
|
|
14
|
+
return { id: name, name, dataType: 'enum', required: false, config: { choices } };
|
|
15
|
+
}
|
|
16
|
+
function textAttr(name: string): AttributeDef {
|
|
17
|
+
return { id: name, name, dataType: 'text', required: false, config: {} };
|
|
18
|
+
}
|
|
19
|
+
function type(attrs: AttributeDef[]): EntityTypeDef {
|
|
20
|
+
return { id: 'topic', key: 'topic', label: 'Topic', attributes: attrs };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('deriveReviewTransitions', () => {
|
|
24
|
+
it('maps the canonical OGMC pipeline (suggested→ready→rejected→written)', () => {
|
|
25
|
+
const t = deriveReviewTransitions(['suggested', 'ready', 'rejected', 'written']);
|
|
26
|
+
expect(t.approve).toBe('ready');
|
|
27
|
+
expect(t.reject).toBe('rejected');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('skips terminal (published-like) stages when choosing Approve', () => {
|
|
31
|
+
// No dedicated "ready" — approve should still avoid the terminal "written".
|
|
32
|
+
const t = deriveReviewTransitions(['new', 'rejected', 'written']);
|
|
33
|
+
expect(t.reject).toBe('rejected');
|
|
34
|
+
// only forward non-terminal, non-reject candidate is none → falls back to the
|
|
35
|
+
// first successor that isn't the reject stage → 'written' (best available).
|
|
36
|
+
expect(t.approve).toBe('written');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('detects varied rejection words', () => {
|
|
40
|
+
expect(deriveReviewTransitions(['open', 'approved', 'declined']).reject).toBe('declined');
|
|
41
|
+
expect(deriveReviewTransitions(['inbox', 'live', 'archived']).reject).toBe('archived');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('honours explicit overrides (including nulling a button off)', () => {
|
|
45
|
+
const t = deriveReviewTransitions(['a', 'b', 'c'], { approve: 'c', reject: null });
|
|
46
|
+
expect(t.approve).toBe('c');
|
|
47
|
+
expect(t.reject).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns nulls for an empty choice set', () => {
|
|
51
|
+
expect(deriveReviewTransitions([])).toEqual({ approve: null, reject: null });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('pickStatusAttr', () => {
|
|
56
|
+
it('prefers an attr named "status"', () => {
|
|
57
|
+
const t = type([enumAttr('kind', ['a', 'b']), enumAttr('status', ['x', 'y'])]);
|
|
58
|
+
expect(pickStatusAttr(t)?.name).toBe('status');
|
|
59
|
+
});
|
|
60
|
+
it('falls back to the first enum with choices', () => {
|
|
61
|
+
const t = type([textAttr('title'), enumAttr('stage', ['x', 'y'])]);
|
|
62
|
+
expect(pickStatusAttr(t)?.name).toBe('stage');
|
|
63
|
+
});
|
|
64
|
+
it('returns null when no enum attr has choices', () => {
|
|
65
|
+
expect(pickStatusAttr(type([textAttr('title')]))).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('resolveReviewConfig', () => {
|
|
70
|
+
const topic = type([
|
|
71
|
+
textAttr('title'),
|
|
72
|
+
textAttr('angle'),
|
|
73
|
+
enumAttr('content_type', ['weekly_brief', 'lead_magnet', 'general']),
|
|
74
|
+
enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
|
|
75
|
+
textAttr('market'),
|
|
76
|
+
textAttr('team_verdict'),
|
|
77
|
+
textAttr('team_notes'),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
it('resolves the OGMC defaults from the live schema', () => {
|
|
81
|
+
const r = resolveReviewConfig(topic);
|
|
82
|
+
expect(r.statusName).toBe('status');
|
|
83
|
+
expect(r.transitions).toEqual({ approve: 'ready', reject: 'rejected' });
|
|
84
|
+
expect(r.verdictAttr).toBe('team_verdict');
|
|
85
|
+
expect(r.noteAttr).toBe('team_notes');
|
|
86
|
+
expect(r.verdicts).toBe(DEFAULT_VERDICTS);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('drops meta/source attrs the type does not declare', () => {
|
|
90
|
+
const r = resolveReviewConfig(topic);
|
|
91
|
+
// default meta ['content_type','market'] both declared; default sources none declared
|
|
92
|
+
expect(r.metaAttrs).toEqual(['content_type', 'market']);
|
|
93
|
+
expect(r.sourceAttrs).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('lets overrides win', () => {
|
|
97
|
+
const r = resolveReviewConfig(topic, { rejectStatus: 'written', verdictAttr: 'vote' });
|
|
98
|
+
expect(r.transitions.reject).toBe('written');
|
|
99
|
+
expect(r.verdictAttr).toBe('vote');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('shouldIgnoreShortcut', () => {
|
|
104
|
+
it('ignores while typing in a form control', () => {
|
|
105
|
+
expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true);
|
|
106
|
+
expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'TEXTAREA' } })).toBe(true);
|
|
107
|
+
expect(shouldIgnoreShortcut({ key: 'a', target: { isContentEditable: true } })).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('ignores modified keys', () => {
|
|
110
|
+
expect(shouldIgnoreShortcut({ key: 'a', metaKey: true, target: { tagName: 'DIV' } })).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
it('fires on a bare letter over non-input', () => {
|
|
113
|
+
expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'DIV' } })).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('nextReviewIndex', () => {
|
|
118
|
+
it('advances to the next item', () => {
|
|
119
|
+
expect(nextReviewIndex(['a', 'b', 'c'], 'a')).toBe(1);
|
|
120
|
+
expect(nextReviewIndex(['a', 'b', 'c'], 'b')).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
it('steps back when acting on the last item', () => {
|
|
123
|
+
expect(nextReviewIndex(['a', 'b', 'c'], 'c')).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
it('returns -1 when the list would empty', () => {
|
|
126
|
+
expect(nextReviewIndex(['a'], 'a')).toBe(-1);
|
|
127
|
+
expect(nextReviewIndex([], 'a')).toBe(-1);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('reviewHostOf / fieldStr', () => {
|
|
132
|
+
it('bare-hosts a url with or without scheme', () => {
|
|
133
|
+
expect(reviewHostOf('https://www.reuters.com/x')).toBe('reuters.com');
|
|
134
|
+
expect(reviewHostOf('gov.ae/notice')).toBe('gov.ae');
|
|
135
|
+
expect(reviewHostOf('not a url')).toBe('');
|
|
136
|
+
});
|
|
137
|
+
it('reads camel or snake keys off the data blob', () => {
|
|
138
|
+
expect(fieldStr({ teamVerdict: 'good' }, 'team_verdict')).toBe('good');
|
|
139
|
+
expect(fieldStr({ team_verdict: 'bad' }, 'team_verdict')).toBe('bad');
|
|
140
|
+
expect(fieldStr(undefined, 'x')).toBe('');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -7,6 +7,21 @@
|
|
|
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
|
+
pickStatusAttr,
|
|
15
|
+
shouldIgnoreShortcut,
|
|
16
|
+
nextReviewIndex,
|
|
17
|
+
choicesOf,
|
|
18
|
+
DEFAULT_VERDICTS,
|
|
19
|
+
type ReviewConfig,
|
|
20
|
+
type ResolvedReview,
|
|
21
|
+
type ReviewTransitions,
|
|
22
|
+
type VerdictOption,
|
|
23
|
+
type VerdictTone,
|
|
24
|
+
} from './review';
|
|
10
25
|
export type { CollectionClient, EntityRecord, EntityTypeDef, AttributeDef, Paginated, DataType } from './types';
|
|
11
26
|
export { readData, toCamelKey } from './data';
|
|
12
27
|
export {
|
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
// ---- small read helpers used by the component (kept here so they're tested) ----
|
|
219
|
+
|
|
220
|
+
/** String read of an attr off a record's data blob ('' when missing). */
|
|
221
|
+
export function fieldStr(data: Record<string, unknown> | undefined, name: string): string {
|
|
222
|
+
const v = readData(data, name);
|
|
223
|
+
return v == null ? '' : String(v);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Normalise a url to its bare host (lowercased, `www.` stripped). '' if unparseable. */
|
|
227
|
+
export function reviewHostOf(url: string): string {
|
|
228
|
+
try {
|
|
229
|
+
return new URL(/^https?:\/\//.test(url) ? url : `https://${url}`).hostname
|
|
230
|
+
.toLowerCase()
|
|
231
|
+
.replace(/^www\./, '');
|
|
232
|
+
} catch {
|
|
233
|
+
return '';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The index to move to after acting on `current` within `ids`. Returns the next
|
|
239
|
+
* item, or the previous when acting on the last, or -1 when the list empties — so
|
|
240
|
+
* an Approve/Reject can auto-advance the reviewer to fresh work without a dead end.
|
|
241
|
+
*/
|
|
242
|
+
export function nextReviewIndex(ids: string[], currentId: string): number {
|
|
243
|
+
const i = ids.indexOf(currentId);
|
|
244
|
+
if (i === -1) return ids.length > 0 ? 0 : -1;
|
|
245
|
+
if (i + 1 < ids.length) return i + 1;
|
|
246
|
+
if (i - 1 >= 0) return i - 1;
|
|
247
|
+
return -1;
|
|
248
|
+
}
|