@startsimpli/ui 0.4.156 → 0.4.158
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 +3 -3
- package/src/components/collection/InlineReviewActions.tsx +7 -1
- package/src/components/collection/ReviewDrawer.tsx +61 -25
- package/src/components/collection/__tests__/active-decision-inert.test.tsx +155 -0
- package/src/components/collection/__tests__/review-drawer-reader.test.tsx +119 -0
- package/src/components/collection/index.ts +1 -0
- package/src/components/collection/review.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.158",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -55,9 +55,9 @@
|
|
|
55
55
|
"@codemirror/language": "^6.0.0",
|
|
56
56
|
"@codemirror/state": "^6.0.0",
|
|
57
57
|
"@codemirror/view": "^6.0.0",
|
|
58
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
58
59
|
"@startsimpli/api": "^0.5.68",
|
|
59
|
-
"@startsimpli/auth": "^0.4.42"
|
|
60
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
60
|
+
"@startsimpli/auth": "^0.4.42"
|
|
61
61
|
},
|
|
62
62
|
"peerDependenciesMeta": {
|
|
63
63
|
"@startsimpli/api": {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
activeDecisionKey,
|
|
33
33
|
applyDecisionToData,
|
|
34
34
|
fieldStr,
|
|
35
|
+
isDecisionInForce,
|
|
35
36
|
resolveReviewConfig,
|
|
36
37
|
reviewDecisions,
|
|
37
38
|
type ReviewConfig,
|
|
@@ -126,19 +127,24 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
126
127
|
function iconButton(d: ReviewDecision, extraOnClick?: () => void) {
|
|
127
128
|
const Icon = ICON[d.tone];
|
|
128
129
|
const active = d.key === appliedKey;
|
|
130
|
+
// The decision already in force writes nothing (bd startsim-3c7h8). Not
|
|
131
|
+
// `disabled`: that dims the lit tick and it would read as un-approved.
|
|
132
|
+
const inert = isDecisionInForce(d, appliedKey);
|
|
129
133
|
return (
|
|
130
134
|
<button
|
|
131
135
|
type="button"
|
|
132
136
|
title={d.label}
|
|
133
137
|
aria-label={d.label}
|
|
134
138
|
aria-pressed={active}
|
|
139
|
+
aria-disabled={inert || undefined}
|
|
135
140
|
disabled={busy === d.key}
|
|
136
141
|
onClick={(e) => {
|
|
137
142
|
e.stopPropagation();
|
|
143
|
+
if (inert) return;
|
|
138
144
|
if (extraOnClick) extraOnClick();
|
|
139
145
|
else void apply(d);
|
|
140
146
|
}}
|
|
141
|
-
className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone])}
|
|
147
|
+
className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone], inert && 'cursor-default')}
|
|
142
148
|
>
|
|
143
149
|
<Icon className="h-4 w-4" />
|
|
144
150
|
</button>
|
|
@@ -46,6 +46,7 @@ import { declaredBlob, isVersionNumber, readData, writeData } from './data';
|
|
|
46
46
|
import { parseSources } from './drafts';
|
|
47
47
|
import {
|
|
48
48
|
activeDecisionKey,
|
|
49
|
+
isDecisionInForce,
|
|
49
50
|
resolveReviewConfig,
|
|
50
51
|
reviewDecisions,
|
|
51
52
|
shouldIgnoreShortcut,
|
|
@@ -92,6 +93,16 @@ export interface ReviewDrawerProps {
|
|
|
92
93
|
* see the decision that was just made.
|
|
93
94
|
*/
|
|
94
95
|
renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
|
|
96
|
+
/**
|
|
97
|
+
* False for a reader who cannot change this record (bd startsim-m7fdm.32).
|
|
98
|
+
* The record, its sources, its note and prev/next stay; the State select,
|
|
99
|
+
* "Edit fields", the decisions, "Add note" and their shortcuts are not drawn,
|
|
100
|
+
* so a view-only reader is not offered a control that can only end in
|
|
101
|
+
* "Could not save.". A control the person cannot use is hidden, not greyed
|
|
102
|
+
* out. The host decides it per record (a share on the record answers).
|
|
103
|
+
* Defaults to true.
|
|
104
|
+
*/
|
|
105
|
+
canEdit?: boolean;
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
export function ReviewDrawer(props: ReviewDrawerProps) {
|
|
@@ -111,6 +122,7 @@ function ReviewDrawerInner({
|
|
|
111
122
|
sourceTierByHost,
|
|
112
123
|
renderEditFields,
|
|
113
124
|
renderExtra,
|
|
125
|
+
canEdit = true,
|
|
114
126
|
}: ReviewDrawerProps & { record: EntityRecord }) {
|
|
115
127
|
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
116
128
|
const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
|
|
@@ -206,6 +218,13 @@ function ReviewDrawerInner({
|
|
|
206
218
|
|
|
207
219
|
/** One decision → write status AND verdict together (the coherent pair). */
|
|
208
220
|
function applyDecision(d: ReviewDecision) {
|
|
221
|
+
// Restating the decision in force writes nothing (bd startsim-3c7h8) — the
|
|
222
|
+
// button and its shortcut both land here. It still walks the queue, so
|
|
223
|
+
// `a` over already-approved work keeps moving the reviewer on.
|
|
224
|
+
if (isDecisionInForce(d, activeKey)) {
|
|
225
|
+
if (d.advance && next) onNavigate(next);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
209
228
|
const changes: Record<string, unknown> = {};
|
|
210
229
|
if (d.status) changes[cfg.statusName] = d.status;
|
|
211
230
|
if (d.verdict) changes[cfg.verdictAttr] = d.verdict;
|
|
@@ -229,7 +248,7 @@ function ReviewDrawerInner({
|
|
|
229
248
|
if (mode !== 'review') return;
|
|
230
249
|
function onKey(e: KeyboardEvent) {
|
|
231
250
|
if (shouldIgnoreShortcut(e)) return;
|
|
232
|
-
const d = decisions.find((x) => x.shortcut === e.key);
|
|
251
|
+
const d = canEdit ? decisions.find((x) => x.shortcut === e.key) : undefined;
|
|
233
252
|
if (d) {
|
|
234
253
|
e.preventDefault();
|
|
235
254
|
applyDecision(d);
|
|
@@ -244,16 +263,20 @@ function ReviewDrawerInner({
|
|
|
244
263
|
case 'ArrowUp':
|
|
245
264
|
if (prev) { e.preventDefault(); onNavigate(prev); }
|
|
246
265
|
break;
|
|
247
|
-
case 'e':
|
|
266
|
+
case 'e':
|
|
267
|
+
if (canEdit) { e.preventDefault(); setMode('edit'); }
|
|
268
|
+
break;
|
|
248
269
|
case 'Escape': e.preventDefault(); onClose(); break;
|
|
249
270
|
}
|
|
250
271
|
}
|
|
251
272
|
window.addEventListener('keydown', onKey);
|
|
252
273
|
return () => window.removeEventListener('keydown', onKey);
|
|
253
274
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
-
}, [mode, prev, next, decisions, data]);
|
|
275
|
+
}, [mode, prev, next, decisions, data, canEdit]);
|
|
255
276
|
|
|
256
|
-
const legend =
|
|
277
|
+
const legend = canEdit
|
|
278
|
+
? [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ')
|
|
279
|
+
: 'j/k move';
|
|
257
280
|
|
|
258
281
|
return (
|
|
259
282
|
<div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
|
|
@@ -270,7 +293,13 @@ function ReviewDrawerInner({
|
|
|
270
293
|
</div>
|
|
271
294
|
<div className="flex shrink-0 items-center gap-1">
|
|
272
295
|
{/* Current state — and the rare manual override (mark written, un-reject). */}
|
|
273
|
-
{
|
|
296
|
+
{!canEdit ? (
|
|
297
|
+
status ? (
|
|
298
|
+
<span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
|
|
299
|
+
{status.replace(/_/g, ' ')}
|
|
300
|
+
</span>
|
|
301
|
+
) : null
|
|
302
|
+
) : cfg.statusChoices.length > 0 ? (
|
|
274
303
|
<Select value={status || undefined} onValueChange={overrideStatus}>
|
|
275
304
|
<SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
|
|
276
305
|
<SelectValue placeholder="State…" />
|
|
@@ -302,13 +331,15 @@ function ReviewDrawerInner({
|
|
|
302
331
|
>
|
|
303
332
|
<ChevronRight className="h-4 w-4" />
|
|
304
333
|
</button>
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
334
|
+
{canEdit ? (
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => setMode((m) => (m === 'review' ? 'edit' : 'review'))}
|
|
337
|
+
className="ml-1 rounded border border-border px-2.5 py-1 text-xs hover:bg-muted"
|
|
338
|
+
title="Edit fields (e)"
|
|
339
|
+
>
|
|
340
|
+
{mode === 'review' ? 'Edit fields' : 'Review'}
|
|
341
|
+
</button>
|
|
342
|
+
) : null}
|
|
312
343
|
<button
|
|
313
344
|
onClick={onClose}
|
|
314
345
|
className="rounded p-1 text-muted-foreground hover:bg-muted"
|
|
@@ -319,7 +350,7 @@ function ReviewDrawerInner({
|
|
|
319
350
|
</div>
|
|
320
351
|
</header>
|
|
321
352
|
|
|
322
|
-
{mode === 'edit' ? (
|
|
353
|
+
{mode === 'edit' && canEdit ? (
|
|
323
354
|
<div className="flex-1 overflow-y-auto px-4 py-4">
|
|
324
355
|
{renderEditFields ? (
|
|
325
356
|
renderEditFields({
|
|
@@ -374,7 +405,7 @@ function ReviewDrawerInner({
|
|
|
374
405
|
</div>
|
|
375
406
|
|
|
376
407
|
<footer className="space-y-3 border-t border-border px-4 py-3">
|
|
377
|
-
{decisions.length > 0 ? (
|
|
408
|
+
{canEdit && decisions.length > 0 ? (
|
|
378
409
|
<div className="flex items-stretch gap-2">
|
|
379
410
|
{decisions.map((d) => (
|
|
380
411
|
<DecisionButton
|
|
@@ -389,20 +420,24 @@ function ReviewDrawerInner({
|
|
|
389
420
|
) : null}
|
|
390
421
|
|
|
391
422
|
<div className="flex items-center gap-2">
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
423
|
+
{canEdit ? (
|
|
424
|
+
<button
|
|
425
|
+
onClick={() => setNoteOpen((o) => !o)}
|
|
426
|
+
className={cn(
|
|
427
|
+
'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
|
|
428
|
+
note && 'text-primary',
|
|
429
|
+
)}
|
|
430
|
+
>
|
|
431
|
+
<MessageSquarePlus className="h-3.5 w-3.5" />
|
|
432
|
+
{note ? 'Note ✓' : 'Add note'}
|
|
433
|
+
</button>
|
|
434
|
+
) : (
|
|
435
|
+
<span className="text-xs text-muted-foreground">You can view this {type.label.toLowerCase()}.</span>
|
|
436
|
+
)}
|
|
402
437
|
<span className="ml-auto text-[11px] text-muted-foreground">{legend}</span>
|
|
403
438
|
</div>
|
|
404
439
|
|
|
405
|
-
{noteOpen ? (
|
|
440
|
+
{noteOpen && canEdit ? (
|
|
406
441
|
<div className="space-y-2">
|
|
407
442
|
<Textarea
|
|
408
443
|
value={note}
|
|
@@ -467,6 +502,7 @@ function DecisionButton({
|
|
|
467
502
|
type="button"
|
|
468
503
|
onClick={onClick}
|
|
469
504
|
disabled={busy}
|
|
505
|
+
aria-disabled={(active && !decision.promptNote) || undefined}
|
|
470
506
|
title={`${decision.label} (${decision.shortcut})`}
|
|
471
507
|
className={cn(
|
|
472
508
|
'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',
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision already in force is inert (bd startsim-3c7h8, startsim-tpz67,
|
|
3
|
+
* startsim-nyqbw).
|
|
4
|
+
*
|
|
5
|
+
* A lit Approve means the topic is `ready` or past it (`written`). Clicking it
|
|
6
|
+
* again used to write `status: ready` over a `written` topic, which moved it
|
|
7
|
+
* backwards and made the host treat the save as a fresh approval: it navigated
|
|
8
|
+
* to the story page and dispatched a writer the server then refused
|
|
9
|
+
* (drafts_exist). Clicking or pressing the decision that is already in force
|
|
10
|
+
* must write nothing.
|
|
11
|
+
*/
|
|
12
|
+
import * as React from 'react';
|
|
13
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
14
|
+
import userEvent from '@testing-library/user-event';
|
|
15
|
+
|
|
16
|
+
import { InlineReviewActions } from '../InlineReviewActions';
|
|
17
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
18
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
19
|
+
|
|
20
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
21
|
+
id: 1,
|
|
22
|
+
key: 'topic',
|
|
23
|
+
label: 'Topic',
|
|
24
|
+
attributes: [
|
|
25
|
+
{
|
|
26
|
+
id: 'status',
|
|
27
|
+
name: 'status',
|
|
28
|
+
dataType: 'enum',
|
|
29
|
+
required: false,
|
|
30
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
31
|
+
},
|
|
32
|
+
{ id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
|
|
33
|
+
{ id: 'team_verdict', name: 'team_verdict', dataType: 'text', required: false, config: {} },
|
|
34
|
+
],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function topic(id: number, status: string): EntityRecord {
|
|
38
|
+
return {
|
|
39
|
+
id,
|
|
40
|
+
entityType: 'topic',
|
|
41
|
+
externalId: null,
|
|
42
|
+
name: `Topic ${id}`,
|
|
43
|
+
data: { title: `Topic ${id}`, status, team_verdict: 'good' },
|
|
44
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
49
|
+
return { count: results.length, next: null, previous: null, results };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
|
|
53
|
+
const updateEntity = jest.fn(async (id: number | string, input: { data?: Record<string, unknown> }) => ({
|
|
54
|
+
...topic(Number(id), 'x'),
|
|
55
|
+
data: input.data ?? {},
|
|
56
|
+
}));
|
|
57
|
+
return {
|
|
58
|
+
listTypes: jest.fn(async () => page([TOPIC_TYPE])),
|
|
59
|
+
listAllEntities: jest.fn(async () => []),
|
|
60
|
+
updateEntity,
|
|
61
|
+
} as CollectionClient & { updateEntity: jest.Mock };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('InlineReviewActions: the lit decision writes nothing', () => {
|
|
65
|
+
it.each(['written', 'ready'])('clicking the lit Approve on a %s topic does not save', async (status) => {
|
|
66
|
+
const user = userEvent.setup();
|
|
67
|
+
const client = stubClient();
|
|
68
|
+
const onSaved = jest.fn();
|
|
69
|
+
render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, status)} onSaved={onSaved} />);
|
|
70
|
+
|
|
71
|
+
const approve = screen.getByRole('button', { name: /Approve/ });
|
|
72
|
+
expect(approve).toHaveAttribute('aria-pressed', 'true');
|
|
73
|
+
expect(approve).toHaveAttribute('aria-disabled', 'true');
|
|
74
|
+
|
|
75
|
+
await user.click(approve);
|
|
76
|
+
|
|
77
|
+
expect(client.updateEntity).not.toHaveBeenCalled();
|
|
78
|
+
expect(onSaved).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('a decision that is NOT in force still saves', async () => {
|
|
82
|
+
const user = userEvent.setup();
|
|
83
|
+
const client = stubClient();
|
|
84
|
+
render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, 'written')} />);
|
|
85
|
+
|
|
86
|
+
await user.click(screen.getByRole('button', { name: /Reject/ }));
|
|
87
|
+
|
|
88
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
89
|
+
expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({ status: 'rejected' });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('Approve on a suggested topic still saves ready', async () => {
|
|
93
|
+
const user = userEvent.setup();
|
|
94
|
+
const client = stubClient();
|
|
95
|
+
render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(1, 'suggested')} />);
|
|
96
|
+
|
|
97
|
+
const approve = screen.getByRole('button', { name: /Approve/ });
|
|
98
|
+
expect(approve).toHaveAttribute('aria-pressed', 'false');
|
|
99
|
+
await user.click(approve);
|
|
100
|
+
|
|
101
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
102
|
+
expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({ status: 'ready' });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('ReviewDrawer: the decision in force writes nothing', () => {
|
|
107
|
+
function renderDrawer(status: string) {
|
|
108
|
+
const client = stubClient();
|
|
109
|
+
const onNavigate = jest.fn();
|
|
110
|
+
const onSaved = jest.fn();
|
|
111
|
+
const first = topic(1, status);
|
|
112
|
+
render(
|
|
113
|
+
<ReviewDrawer
|
|
114
|
+
client={client}
|
|
115
|
+
type={TOPIC_TYPE}
|
|
116
|
+
records={[first, topic(2, 'suggested')]}
|
|
117
|
+
record={first}
|
|
118
|
+
onClose={jest.fn()}
|
|
119
|
+
onNavigate={onNavigate}
|
|
120
|
+
onSaved={onSaved}
|
|
121
|
+
/>,
|
|
122
|
+
);
|
|
123
|
+
return { client, onNavigate, onSaved };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
it('clicking Approve on a written topic does not save, and still walks the queue', async () => {
|
|
127
|
+
const user = userEvent.setup();
|
|
128
|
+
const { client, onSaved, onNavigate } = renderDrawer('written');
|
|
129
|
+
|
|
130
|
+
await user.click(screen.getByRole('button', { name: /Approve/ }));
|
|
131
|
+
|
|
132
|
+
expect(client.updateEntity).not.toHaveBeenCalled();
|
|
133
|
+
expect(onSaved).not.toHaveBeenCalled();
|
|
134
|
+
expect(onNavigate).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(onNavigate.mock.calls[0][0].id).toBe(2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('the bare-key shortcut for the decision in force does not save either', async () => {
|
|
139
|
+
const user = userEvent.setup();
|
|
140
|
+
const { client } = renderDrawer('written');
|
|
141
|
+
|
|
142
|
+
await user.keyboard('a');
|
|
143
|
+
|
|
144
|
+
expect(client.updateEntity).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('a different decision from the drawer still saves', async () => {
|
|
148
|
+
const user = userEvent.setup();
|
|
149
|
+
const { client } = renderDrawer('written');
|
|
150
|
+
|
|
151
|
+
await user.click(screen.getByRole('button', { name: /Reject/ }));
|
|
152
|
+
|
|
153
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A reader who cannot change the record is not offered controls that can only
|
|
3
|
+
* end in "Could not save." (bd startsim-m7fdm.32, review bead startsim-j9agp).
|
|
4
|
+
*
|
|
5
|
+
* Before this, the drawer drew the State select, "Edit fields", Approve /
|
|
6
|
+
* Needs work / Reject and "Add note" for everyone, and a view-only reader
|
|
7
|
+
* learned the truth from an error toast after pressing one. The host now says
|
|
8
|
+
* per record whether it may be changed, and the drawer draws accordingly.
|
|
9
|
+
*/
|
|
10
|
+
import * as React from 'react';
|
|
11
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
12
|
+
|
|
13
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
14
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
15
|
+
|
|
16
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
17
|
+
id: 1,
|
|
18
|
+
key: 'topic',
|
|
19
|
+
label: 'Topic',
|
|
20
|
+
attributes: [
|
|
21
|
+
{
|
|
22
|
+
id: 'status',
|
|
23
|
+
name: 'status',
|
|
24
|
+
dataType: 'enum',
|
|
25
|
+
required: false,
|
|
26
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
27
|
+
},
|
|
28
|
+
{ id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
|
|
29
|
+
{ id: 'team_notes', name: 'team_notes', dataType: 'text', required: false, config: {} },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function topic(id: number, title: string, note = ''): EntityRecord {
|
|
34
|
+
return {
|
|
35
|
+
id,
|
|
36
|
+
entityType: 'topic',
|
|
37
|
+
externalId: null,
|
|
38
|
+
name: title,
|
|
39
|
+
data: { title, status: 'suggested', ...(note ? { team_notes: note } : {}) },
|
|
40
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
45
|
+
return { count: results.length, next: null, previous: null, results };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
|
|
49
|
+
const updateEntity = jest.fn(async () => topic(1, 'x'));
|
|
50
|
+
return {
|
|
51
|
+
listTypes: jest.fn(async () => page([TOPIC_TYPE])),
|
|
52
|
+
listAllEntities: jest.fn(async () => []),
|
|
53
|
+
updateEntity,
|
|
54
|
+
} as CollectionClient & { updateEntity: jest.Mock };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function renderDrawer(canEdit: boolean | undefined, record = topic(1, 'Oman customs')) {
|
|
58
|
+
const client = stubClient();
|
|
59
|
+
const onNavigate = jest.fn();
|
|
60
|
+
const records = [record, topic(2, 'Bahrain filing')];
|
|
61
|
+
render(
|
|
62
|
+
<ReviewDrawer
|
|
63
|
+
client={client}
|
|
64
|
+
type={TOPIC_TYPE}
|
|
65
|
+
records={records}
|
|
66
|
+
record={record}
|
|
67
|
+
onClose={() => {}}
|
|
68
|
+
onNavigate={onNavigate}
|
|
69
|
+
renderEditFields={() => <div>FIELD EDITOR</div>}
|
|
70
|
+
{...(canEdit === undefined ? {} : { canEdit })}
|
|
71
|
+
/>,
|
|
72
|
+
);
|
|
73
|
+
return { client, onNavigate };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('ReviewDrawer for a view-only reader', () => {
|
|
77
|
+
it('draws no State select, Edit fields, decisions or Add note', () => {
|
|
78
|
+
renderDrawer(false);
|
|
79
|
+
|
|
80
|
+
expect(screen.getByText('Oman customs')).toBeInTheDocument();
|
|
81
|
+
expect(screen.queryByRole('combobox')).toBeNull();
|
|
82
|
+
expect(screen.queryByRole('button', { name: /edit fields/i })).toBeNull();
|
|
83
|
+
expect(screen.queryByRole('button', { name: /approve/i })).toBeNull();
|
|
84
|
+
expect(screen.queryByRole('button', { name: /reject/i })).toBeNull();
|
|
85
|
+
expect(screen.queryByRole('button', { name: /needs work/i })).toBeNull();
|
|
86
|
+
expect(screen.queryByRole('button', { name: /add note/i })).toBeNull();
|
|
87
|
+
expect(screen.getByText('You can view this topic.')).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('still shows where the record stands, and its note', () => {
|
|
91
|
+
renderDrawer(false, topic(1, 'Oman customs', 'Angle too broad.'));
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText('suggested')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByText('Angle too broad.')).toBeInTheDocument();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('ignores the decision and edit shortcuts, and still walks the queue', () => {
|
|
98
|
+
const { client, onNavigate } = renderDrawer(false);
|
|
99
|
+
|
|
100
|
+
fireEvent.keyDown(window, { key: 'a' });
|
|
101
|
+
fireEvent.keyDown(window, { key: 'x' });
|
|
102
|
+
fireEvent.keyDown(window, { key: 'e' });
|
|
103
|
+
expect(client.updateEntity).not.toHaveBeenCalled();
|
|
104
|
+
expect(screen.queryByText('FIELD EDITOR')).toBeNull();
|
|
105
|
+
|
|
106
|
+
fireEvent.keyDown(window, { key: 'j' });
|
|
107
|
+
expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ id: 2 }));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('an editor still gets every control (the default)', () => {
|
|
111
|
+
renderDrawer(undefined);
|
|
112
|
+
|
|
113
|
+
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
|
114
|
+
expect(screen.getByRole('button', { name: /edit fields/i })).toBeInTheDocument();
|
|
115
|
+
expect(screen.getByRole('button', { name: /approve/i })).toBeInTheDocument();
|
|
116
|
+
expect(screen.getByRole('button', { name: /add note/i })).toBeInTheDocument();
|
|
117
|
+
expect(screen.queryByText('You can view this topic.')).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -475,6 +475,20 @@ export function activeDecisionKey(
|
|
|
475
475
|
return null;
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
+
/**
|
|
479
|
+
* True when clicking `d` would only restate the decision already in force, so
|
|
480
|
+
* the click must write nothing (bd startsim-3c7h8). A lit Approve on a
|
|
481
|
+
* `written` topic used to write `status: ready` over it: the topic went
|
|
482
|
+
* backwards and the host read the save as a fresh approval, navigating to a
|
|
483
|
+
* story page that claimed a writer was running when the server had refused one.
|
|
484
|
+
*
|
|
485
|
+
* A note-prompting decision is never inert: re-opening it is how a reviewer
|
|
486
|
+
* edits the note that came with it.
|
|
487
|
+
*/
|
|
488
|
+
export function isDecisionInForce(d: ReviewDecision, activeKey: string | null): boolean {
|
|
489
|
+
return !d.promptNote && activeKey != null && d.key === activeKey;
|
|
490
|
+
}
|
|
491
|
+
|
|
478
492
|
// ---- small read helpers used by the component (kept here so they're tested) ----
|
|
479
493
|
|
|
480
494
|
/** String read of an attr off a record's data blob ('' when missing). */
|