@startsimpli/ui 0.4.111 → 0.4.113
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/DraftReviewWorkspace.tsx +29 -3
- package/src/components/collection/ReviewDrawer.tsx +24 -2
- package/src/components/collection/TopicReviewWorkspace.tsx +28 -2
- package/src/components/collection/__tests__/approve-unlocks.test.tsx +169 -0
- package/src/components/collection/__tests__/review.test.ts +59 -0
- package/src/components/collection/__tests__/scoped-client.test.ts +243 -0
- package/src/components/collection/__tests__/scoped-workspace.test.tsx +179 -0
- package/src/components/collection/index.ts +22 -1
- package/src/components/collection/review.ts +27 -2
- package/src/components/collection/scoped-client.ts +155 -0
- package/src/components/collection/types.ts +52 -1
- package/src/components/index.ts +1 -0
- package/src/components/scope/__tests__/use-scoped-client.test.tsx +80 -0
- package/src/components/scope/index.ts +6 -0
- package/src/components/scope/useScopedClient.ts +44 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.113",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
48
48
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
49
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
-
"@startsimpli/api": "^0.5.58",
|
|
51
50
|
"@startsimpli/auth": "^0.4.35",
|
|
52
|
-
"@startsimpli/i18n": "^0.1.3"
|
|
51
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
52
|
+
"@startsimpli/api": "^0.5.58"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@startsimpli/api": {
|
|
@@ -18,6 +18,7 @@ import { notify } from '../toast/use-notify';
|
|
|
18
18
|
import { MarkdownRenderer } from '../blog/MarkdownRenderer';
|
|
19
19
|
import type { CollectionClient, EntityRecord } from './types';
|
|
20
20
|
import { readData, toCamelKey } from './data';
|
|
21
|
+
import { scopeKey } from './scoped-client';
|
|
21
22
|
import {
|
|
22
23
|
groupDrafts,
|
|
23
24
|
parseSources,
|
|
@@ -58,10 +59,14 @@ export interface DraftReviewWorkspaceProps {
|
|
|
58
59
|
|
|
59
60
|
export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
60
61
|
const qc = useQueryClient();
|
|
62
|
+
// The injected client's bound, in the cache key — see the same note on
|
|
63
|
+
// TopicReviewWorkspace: a scope change that does not change the key serves the
|
|
64
|
+
// previous client's drafts under the new client's name.
|
|
65
|
+
const scope = scopeKey(client);
|
|
61
66
|
const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
|
|
62
67
|
const draftType = typesQ.data?.results.find((t) => t.key === 'draft');
|
|
63
68
|
const recordsQ = useQuery({
|
|
64
|
-
queryKey: ['entities', 'draft', 'all'],
|
|
69
|
+
queryKey: ['entities', 'draft', 'all', scope],
|
|
65
70
|
queryFn: () => client.listAllEntities('draft'),
|
|
66
71
|
enabled: !!draftType,
|
|
67
72
|
});
|
|
@@ -70,8 +75,12 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
70
75
|
// A story whose topic was rejected shouldn't keep cluttering the review — its
|
|
71
76
|
// drafts collapse into the "Rejected" section, mirroring the topic list
|
|
72
77
|
// (startsim-ay9l follow-up). draft.topic_ref is the topic's entity id.
|
|
78
|
+
// SCOPED, unlike the source registry below: a topic is a client's own work,
|
|
79
|
+
// not shared corpus, and this read decides which of THIS board's drafts are
|
|
80
|
+
// collapsed as rejected. Reading it unscoped would let another client's topic
|
|
81
|
+
// ids reach the decision.
|
|
73
82
|
const topicsQ = useQuery({
|
|
74
|
-
queryKey: ['entities', 'topic', 'all'],
|
|
83
|
+
queryKey: ['entities', 'topic', 'all', scope],
|
|
75
84
|
queryFn: () => client.listAllEntities('topic'),
|
|
76
85
|
enabled: !!draftType,
|
|
77
86
|
});
|
|
@@ -83,9 +92,11 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
83
92
|
return s;
|
|
84
93
|
}, [topicsQ.data]);
|
|
85
94
|
|
|
95
|
+
// UNSCOPED — the source registry is shared across scopes (epic
|
|
96
|
+
// startsim-ux39t) and this is a domain→tier lookup, not the client's work.
|
|
86
97
|
const sourcesQ = useQuery({
|
|
87
98
|
queryKey: ['entities', 'source', 'all'],
|
|
88
|
-
queryFn: () => client.listAllEntities('source'),
|
|
99
|
+
queryFn: () => client.listAllEntities('source', { scoped: false }),
|
|
89
100
|
enabled: !!draftType,
|
|
90
101
|
});
|
|
91
102
|
const tierByDomain = useMemo(() => {
|
|
@@ -112,6 +123,9 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
112
123
|
const [showRejected, setShowRejected] = useState(false);
|
|
113
124
|
|
|
114
125
|
async function writeEntity(record: EntityRecord, changes: Record<string, unknown>) {
|
|
126
|
+
// The FULL blob, spread from the record: PATCH REPLACES `data`, so an
|
|
127
|
+
// attribute this board never touches — the scope path included — has to be
|
|
128
|
+
// sent back to survive. A write is never a move between scopes.
|
|
115
129
|
const data: Record<string, unknown> = { ...record.data };
|
|
116
130
|
for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
|
|
117
131
|
return client.updateEntity(record.id, { data });
|
|
@@ -153,6 +167,18 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
|
|
|
153
167
|
if (!draftType) {
|
|
154
168
|
return <p className="text-sm text-gray-500">No “draft” type is defined for this app.</p>;
|
|
155
169
|
}
|
|
170
|
+
// See TopicReviewWorkspace: a scoped list can be refused (a type with no
|
|
171
|
+
// declared scope path is a 400), and an unreported refusal renders as an empty
|
|
172
|
+
// board rather than as a problem.
|
|
173
|
+
if (recordsQ.isError) {
|
|
174
|
+
return (
|
|
175
|
+
<p className="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
176
|
+
Could not load drafts
|
|
177
|
+
{scope === 'all' ? '' : ` for ${scope}`}:{' '}
|
|
178
|
+
{recordsQ.error instanceof Error ? recordsQ.error.message : 'the request was refused.'}
|
|
179
|
+
</p>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
156
182
|
|
|
157
183
|
const pickedCount = activeGroups.filter((g) => g.chosen).length;
|
|
158
184
|
const readyCount = activeGroups.filter((g) => g.ready).length;
|
|
@@ -79,7 +79,15 @@ export interface ReviewDrawerProps {
|
|
|
79
79
|
/** Refetch the parent + return to review mode. */
|
|
80
80
|
saved: () => void;
|
|
81
81
|
}) => ReactNode;
|
|
82
|
-
/**
|
|
82
|
+
/**
|
|
83
|
+
* App-specific extra content under the record (e.g. a topic's drafts).
|
|
84
|
+
* Optional.
|
|
85
|
+
*
|
|
86
|
+
* Handed the record AS DECIDED — the drawer's optimistic `data`, not the
|
|
87
|
+
* `record` prop (bd startsim-t1t1k). An extra slot is where a consumer puts
|
|
88
|
+
* the action a decision UNLOCKS, so it is the one place that most needs to
|
|
89
|
+
* see the decision that was just made.
|
|
90
|
+
*/
|
|
83
91
|
renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
|
|
84
92
|
}
|
|
85
93
|
|
|
@@ -119,6 +127,18 @@ function ReviewDrawerInner({
|
|
|
119
127
|
const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
|
|
120
128
|
const summary = fieldStr(data, cfg.summaryAttr);
|
|
121
129
|
const rank = fieldStr(data, cfg.rankAttr);
|
|
130
|
+
/**
|
|
131
|
+
* The record AS DECIDED — the `record` prop carrying this drawer's own
|
|
132
|
+
* optimistic `data` (bd startsim-t1t1k).
|
|
133
|
+
*
|
|
134
|
+
* Everything else here already renders from `data`; `renderExtra` was the one
|
|
135
|
+
* slot still reading the untouched prop, so a consumer affordance gated on
|
|
136
|
+
* status (OGMC's "Generate drafts", legal only once the topic is approved)
|
|
137
|
+
* kept reading the PRE-decision status and kept refusing. The parent's
|
|
138
|
+
* refetch cannot fix that on its own — it invalidates a list, while the
|
|
139
|
+
* drawer holds a record object the parent chose before the decision existed.
|
|
140
|
+
*/
|
|
141
|
+
const decided = useMemo(() => ({ ...record, data }), [record, data]);
|
|
122
142
|
|
|
123
143
|
/** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */
|
|
124
144
|
async function patch(changes: Record<string, unknown>, key: string, advance = false) {
|
|
@@ -304,7 +324,9 @@ function ReviewDrawerInner({
|
|
|
304
324
|
|
|
305
325
|
<ReviewSources data={data} sourceAttrs={cfg.sourceAttrs} tierByHost={sourceTierByHost} />
|
|
306
326
|
|
|
307
|
-
{renderExtra ?
|
|
327
|
+
{renderExtra ? (
|
|
328
|
+
<div className="mt-6 border-t border-border pt-4">{renderExtra(decided, type)}</div>
|
|
329
|
+
) : null}
|
|
308
330
|
</div>
|
|
309
331
|
|
|
310
332
|
<footer className="space-y-3 border-t border-border px-4 py-3">
|
|
@@ -18,6 +18,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
|
|
|
18
18
|
import { notify } from '../toast/use-notify';
|
|
19
19
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
20
20
|
import { readData, toCamelKey } from './data';
|
|
21
|
+
import { scopeKey } from './scoped-client';
|
|
21
22
|
|
|
22
23
|
const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
|
|
23
24
|
const VERDICTS = [
|
|
@@ -52,18 +53,26 @@ export interface TopicReviewWorkspaceProps {
|
|
|
52
53
|
|
|
53
54
|
export function TopicReviewWorkspace({ client, markets = [], renderDetail }: TopicReviewWorkspaceProps) {
|
|
54
55
|
const qc = useQueryClient();
|
|
56
|
+
// The bound the injected client is carrying. IT IS PART OF THE CACHE KEY, and
|
|
57
|
+
// that is not bookkeeping: without it a scope change reuses the previous
|
|
58
|
+
// scope's rows — the request would carry the new scope and the board would
|
|
59
|
+
// still show the old client's topics, which is worse than not scoping at all.
|
|
60
|
+
const scope = scopeKey(client);
|
|
55
61
|
const typesQ = useQuery({ queryKey: ['schema-types'], queryFn: () => client.listTypes() });
|
|
56
62
|
const topicType = typesQ.data?.results.find((t) => t.key === 'topic');
|
|
57
63
|
const recordsQ = useQuery({
|
|
58
|
-
queryKey: ['entities', 'topic', 'all'],
|
|
64
|
+
queryKey: ['entities', 'topic', 'all', scope],
|
|
59
65
|
queryFn: () => client.listAllEntities('topic'),
|
|
60
66
|
enabled: !!topicType,
|
|
61
67
|
});
|
|
62
68
|
const records = recordsQ.data ?? [];
|
|
63
69
|
|
|
70
|
+
// The source registry is SHARED across scopes (epic startsim-ux39t) and this
|
|
71
|
+
// is a tier lookup, not a list of the client's work — so it is read unscoped
|
|
72
|
+
// and keyed without the scope, one fetch for every bound rather than one each.
|
|
64
73
|
const sourcesQ = useQuery({
|
|
65
74
|
queryKey: ['entities', 'source', 'all'],
|
|
66
|
-
queryFn: () => client.listAllEntities('source'),
|
|
75
|
+
queryFn: () => client.listAllEntities('source', { scoped: false }),
|
|
67
76
|
enabled: !!topicType,
|
|
68
77
|
});
|
|
69
78
|
const tierByDomain = useMemo(() => {
|
|
@@ -98,6 +107,9 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
|
|
|
98
107
|
const data: Record<string, unknown> = { ...record.data };
|
|
99
108
|
for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
|
|
100
109
|
try {
|
|
110
|
+
// The FULL blob, spread from the record — so an attribute this workspace
|
|
111
|
+
// never touches (the scope path among them) survives a PATCH that
|
|
112
|
+
// REPLACES the blob rather than merging it. A write is never a move.
|
|
101
113
|
await client.updateEntity(record.id, { data });
|
|
102
114
|
await qc.invalidateQueries({ queryKey: ['entities', 'topic'] });
|
|
103
115
|
} catch (err) {
|
|
@@ -111,6 +123,20 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
|
|
|
111
123
|
if (!topicType) {
|
|
112
124
|
return <p className="text-sm text-gray-500">No “topic” type is defined for this app.</p>;
|
|
113
125
|
}
|
|
126
|
+
// A SCOPED LIST CAN NOW FAIL, so it has to be able to say so. `?scope=` on a
|
|
127
|
+
// type that declares no scope path is a 400 (bd startsim-mj609) — and an
|
|
128
|
+
// unreported error renders as an empty board, which reads as "this client has
|
|
129
|
+
// no topics". Silently empty under an active bound is the same class of lie
|
|
130
|
+
// as silently widening.
|
|
131
|
+
if (recordsQ.isError) {
|
|
132
|
+
return (
|
|
133
|
+
<p className="rounded-lg border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700">
|
|
134
|
+
Could not load topics
|
|
135
|
+
{scope === 'all' ? '' : ` for ${scope}`}:{' '}
|
|
136
|
+
{recordsQ.error instanceof Error ? recordsQ.error.message : 'the request was refused.'}
|
|
137
|
+
</p>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
114
140
|
|
|
115
141
|
return (
|
|
116
142
|
<div className="space-y-6">
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approving a record must leave the reviewer standing on the record they
|
|
3
|
+
* approved, holding whatever the approval unlocked (bd startsim-t1t1k).
|
|
4
|
+
*
|
|
5
|
+
* The report, verbatim: "To create a draft, you first have to approve the
|
|
6
|
+
* Topic, then click out of the window, refresh the page, filter the 'Ready'
|
|
7
|
+
* topics, find the same topic, click approve again and then it generates
|
|
8
|
+
* drafts." Six steps to reach a button that should have been one — and the
|
|
9
|
+
* second approve writes nothing new, so what the reload actually bought was a
|
|
10
|
+
* FRESH RECORD.
|
|
11
|
+
*
|
|
12
|
+
* Two independent mechanisms in this component produced that, and both are
|
|
13
|
+
* pinned below:
|
|
14
|
+
*
|
|
15
|
+
* 1. THE EXTRA SLOT SAW A STALE RECORD. Every other thing the drawer renders
|
|
16
|
+
* — heading, meta chips, the status Select, which decision reads as active
|
|
17
|
+
* — is derived from the optimistic `data` a decision writes. `renderExtra`
|
|
18
|
+
* alone was handed the untouched `record` prop, so the consumer's
|
|
19
|
+
* follow-on affordance (OGMC's "Generate drafts", gated on
|
|
20
|
+
* status === the approve status) kept reading the PRE-approval status and
|
|
21
|
+
* kept refusing. Exactly the state a page reload cures.
|
|
22
|
+
* 2. THE DRAWER MOVED ON. Approve auto-advances, so even once the gate opened
|
|
23
|
+
* it opened on a record that was no longer on screen.
|
|
24
|
+
*
|
|
25
|
+
* Neither test asserts a colour or a class: they assert that the thing the
|
|
26
|
+
* approval unlocked is reachable, for the record that was approved.
|
|
27
|
+
*/
|
|
28
|
+
import * as React from 'react';
|
|
29
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
30
|
+
import userEvent from '@testing-library/user-event';
|
|
31
|
+
|
|
32
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
33
|
+
import { readData } from '../data';
|
|
34
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
35
|
+
|
|
36
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
37
|
+
id: 1,
|
|
38
|
+
key: 'topic',
|
|
39
|
+
label: 'Topic',
|
|
40
|
+
attributes: [
|
|
41
|
+
{
|
|
42
|
+
id: 'status',
|
|
43
|
+
name: 'status',
|
|
44
|
+
dataType: 'enum',
|
|
45
|
+
required: false,
|
|
46
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
47
|
+
},
|
|
48
|
+
{ id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function topic(id: number, title: string): EntityRecord {
|
|
53
|
+
return {
|
|
54
|
+
id,
|
|
55
|
+
entityType: 'topic',
|
|
56
|
+
externalId: null,
|
|
57
|
+
name: title,
|
|
58
|
+
data: { title, status: 'suggested' },
|
|
59
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function page<T>(results: T[]): Paginated<T> {
|
|
64
|
+
return { count: results.length, next: null, previous: null, results };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
|
|
68
|
+
const updateEntity = jest.fn(async (id: number | string, input: { data?: Record<string, unknown> }) => ({
|
|
69
|
+
...topic(Number(id), 'x'),
|
|
70
|
+
data: input.data ?? {},
|
|
71
|
+
}));
|
|
72
|
+
return {
|
|
73
|
+
listTypes: jest.fn(async () => page([TOPIC_TYPE])),
|
|
74
|
+
listAllEntities: jest.fn(async () => []),
|
|
75
|
+
updateEntity,
|
|
76
|
+
} as CollectionClient & { updateEntity: jest.Mock };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The consumer's follow-on affordance, standing in for OGMC's "Generate
|
|
81
|
+
* drafts": legal only once the record has reached the approve status, and it
|
|
82
|
+
* says WHY when it is not — a disabled control the reader cannot explain is the
|
|
83
|
+
* same defect in another shape.
|
|
84
|
+
*/
|
|
85
|
+
function GenerateStub(record: EntityRecord) {
|
|
86
|
+
const approved = String(readData(record.data, 'status') ?? '') === 'ready';
|
|
87
|
+
return approved ? (
|
|
88
|
+
<button type="button">Generate drafts</button>
|
|
89
|
+
) : (
|
|
90
|
+
<span>Approve this topic to generate drafts.</span>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function renderDrawer(overrides: Partial<React.ComponentProps<typeof ReviewDrawer>> = {}) {
|
|
95
|
+
const client = stubClient();
|
|
96
|
+
const onNavigate = jest.fn();
|
|
97
|
+
const onSaved = jest.fn();
|
|
98
|
+
const first = topic(1, 'Desalination in Riyadh');
|
|
99
|
+
const utils = render(
|
|
100
|
+
<ReviewDrawer
|
|
101
|
+
client={client}
|
|
102
|
+
type={TOPIC_TYPE}
|
|
103
|
+
records={[first, topic(2, 'A different topic entirely')]}
|
|
104
|
+
record={first}
|
|
105
|
+
onClose={jest.fn()}
|
|
106
|
+
onNavigate={onNavigate}
|
|
107
|
+
onSaved={onSaved}
|
|
108
|
+
renderExtra={GenerateStub}
|
|
109
|
+
{...overrides}
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
return { ...utils, client, onNavigate, onSaved, first };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('approving a topic unlocks its follow-on action in place', () => {
|
|
116
|
+
it('hands renderExtra the record AS DECIDED, not the stale prop', async () => {
|
|
117
|
+
const user = userEvent.setup();
|
|
118
|
+
const { client } = renderDrawer({ config: { holdAfter: ['approve'] } });
|
|
119
|
+
|
|
120
|
+
// Before: the follow-on is refused, in words.
|
|
121
|
+
expect(screen.getByText('Approve this topic to generate drafts.')).toBeInTheDocument();
|
|
122
|
+
|
|
123
|
+
await user.click(screen.getByRole('button', { name: /Approve/ }));
|
|
124
|
+
|
|
125
|
+
// The write itself carries the coherent pair the pipeline reads.
|
|
126
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalled());
|
|
127
|
+
expect(client.updateEntity.mock.calls[0][1].data).toMatchObject({
|
|
128
|
+
status: 'ready',
|
|
129
|
+
teamVerdict: 'good',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// And the drawer's extra slot reflects it WITHOUT a refetch, a reopen, or a
|
|
133
|
+
// second approve — the parent has not sent a new `record` prop at all.
|
|
134
|
+
expect(await screen.findByRole('button', { name: 'Generate drafts' })).toBeInTheDocument();
|
|
135
|
+
expect(screen.queryByText('Approve this topic to generate drafts.')).not.toBeInTheDocument();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('stays on the approved record when the consumer holds that decision', async () => {
|
|
139
|
+
const user = userEvent.setup();
|
|
140
|
+
const { onNavigate, onSaved } = renderDrawer({ config: { holdAfter: ['approve'] } });
|
|
141
|
+
|
|
142
|
+
await user.click(screen.getByRole('button', { name: /Approve/ }));
|
|
143
|
+
|
|
144
|
+
await waitFor(() => expect(onSaved).toHaveBeenCalled());
|
|
145
|
+
expect(onNavigate).not.toHaveBeenCalled();
|
|
146
|
+
// The heading is still the topic that was decided, not the next one.
|
|
147
|
+
expect(screen.getByRole('heading', { name: 'Desalination in Riyadh' })).toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('still advances after a decision the consumer did NOT hold', async () => {
|
|
151
|
+
const user = userEvent.setup();
|
|
152
|
+
const { onNavigate } = renderDrawer({ config: { holdAfter: ['approve'] } });
|
|
153
|
+
|
|
154
|
+
await user.click(screen.getByRole('button', { name: /Reject/ }));
|
|
155
|
+
|
|
156
|
+
await waitFor(() => expect(onNavigate).toHaveBeenCalled());
|
|
157
|
+
expect(onNavigate.mock.calls[0][0].id).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('advances after Approve for a consumer that holds nothing (unchanged default)', async () => {
|
|
161
|
+
const user = userEvent.setup();
|
|
162
|
+
const { onNavigate } = renderDrawer();
|
|
163
|
+
|
|
164
|
+
await user.click(screen.getByRole('button', { name: /Approve/ }));
|
|
165
|
+
|
|
166
|
+
await waitFor(() => expect(onNavigate).toHaveBeenCalled());
|
|
167
|
+
expect(onNavigate.mock.calls[0][0].id).toBe(2);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -284,3 +284,62 @@ describe('decision labels are the consumer’s to name', () => {
|
|
|
284
284
|
);
|
|
285
285
|
});
|
|
286
286
|
});
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* A decision that UNLOCKS a next action on the same record must not scroll that
|
|
290
|
+
* record off the screen (bd startsim-t1t1k).
|
|
291
|
+
*
|
|
292
|
+
* On OGMC, approving a topic is what makes "Generate drafts" legal for it. The
|
|
293
|
+
* drawer then auto-advanced to the next topic, so the control the approval had
|
|
294
|
+
* just unlocked appeared for a record nobody was looking at — and the reviewer's
|
|
295
|
+
* six-step workaround (close, reload, filter to Ready, find it again, approve a
|
|
296
|
+
* second time) is what she reported. Rejecting has no such follow-on, so it
|
|
297
|
+
* still advances: this is opt-in, per decision key, and every type that does not
|
|
298
|
+
* ask for it is byte-identical.
|
|
299
|
+
*/
|
|
300
|
+
describe('holdAfter — decisions that stay on the record', () => {
|
|
301
|
+
const type = {
|
|
302
|
+
id: 't',
|
|
303
|
+
key: 'topic',
|
|
304
|
+
label: 'Topic',
|
|
305
|
+
attributes: [
|
|
306
|
+
{
|
|
307
|
+
id: 'a',
|
|
308
|
+
name: 'status',
|
|
309
|
+
dataType: 'enum' as const,
|
|
310
|
+
required: false,
|
|
311
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
it('advances after every decision when nothing is held (today’s behaviour)', () => {
|
|
317
|
+
const byKey = Object.fromEntries(
|
|
318
|
+
reviewDecisions(resolveReviewConfig(type)).map((d) => [d.key, d.advance]),
|
|
319
|
+
);
|
|
320
|
+
expect(byKey.approve).toBe(true);
|
|
321
|
+
expect(byKey.reject).toBe(true);
|
|
322
|
+
// "Needs work" already holds — it is meant to be revisited.
|
|
323
|
+
expect(byKey.needs_work).toBe(false);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('holds the named decision and leaves the others advancing', () => {
|
|
327
|
+
const cfg = resolveReviewConfig(type, { holdAfter: ['approve'] });
|
|
328
|
+
const byKey = Object.fromEntries(reviewDecisions(cfg).map((d) => [d.key, d.advance]));
|
|
329
|
+
expect(byKey.approve).toBe(false);
|
|
330
|
+
expect(byKey.reject).toBe(true);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('changes ONLY whether the queue moves on — never what gets written', () => {
|
|
334
|
+
const plain = reviewDecisions(resolveReviewConfig(type));
|
|
335
|
+
const held = reviewDecisions(resolveReviewConfig(type, { holdAfter: ['approve', 'reject'] }));
|
|
336
|
+
expect(held.map((d) => [d.key, d.label, d.status, d.verdict, d.tone, d.shortcut])).toEqual(
|
|
337
|
+
plain.map((d) => [d.key, d.label, d.status, d.verdict, d.tone, d.shortcut]),
|
|
338
|
+
);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('resolves to an empty hold list when the consumer supplies none', () => {
|
|
342
|
+
expect(resolveReviewConfig(type).holdAfter).toEqual([]);
|
|
343
|
+
expect(resolveReviewConfig(type, { holdAfter: ['approve'] }).holdAfter).toEqual(['approve']);
|
|
344
|
+
});
|
|
345
|
+
});
|