@startsimpli/ui 0.4.134 → 0.4.136
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 +5 -4
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/collection/InlineReviewActions.tsx +31 -16
- package/src/components/collection/ReviewDrawer.tsx +15 -2
- package/src/components/collection/__tests__/review.test.ts +149 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +88 -0
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/index.ts +62 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/index.ts +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.136",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./blog": "./src/components/blog/index.ts",
|
|
21
21
|
"./blog/resolve": "./src/components/blog/resolveArticle.ts",
|
|
22
22
|
"./activity": "./src/components/activity/index.ts",
|
|
23
|
+
"./history": "./src/components/history/index.ts",
|
|
23
24
|
"./collection": "./src/components/collection/index.ts",
|
|
24
25
|
"./scope": "./src/components/scope/index.ts",
|
|
25
26
|
"./facet-tree": "./src/components/facet-tree/index.ts",
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
48
49
|
"react": "^18.0.0 || ^19.0.0",
|
|
49
50
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
50
|
-
"@startsimpli/auth": "^0.4.38",
|
|
51
51
|
"@startsimpli/api": "^0.5.66",
|
|
52
|
+
"@startsimpli/auth": "^0.4.38",
|
|
52
53
|
"@startsimpli/i18n": "^0.1.3"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
@@ -123,8 +124,8 @@
|
|
|
123
124
|
"tailwindcss": "^3.4.19",
|
|
124
125
|
"tsup": "^8.5.1",
|
|
125
126
|
"typescript": "^6.0.3",
|
|
126
|
-
"@startsimpli/
|
|
127
|
-
"@startsimpli/
|
|
127
|
+
"@startsimpli/realtime": "0.0.2",
|
|
128
|
+
"@startsimpli/i18n": "0.1.3"
|
|
128
129
|
},
|
|
129
130
|
"keywords": [
|
|
130
131
|
"ui",
|
|
@@ -17,7 +17,7 @@ import { formatDistanceToNow } from 'date-fns';
|
|
|
17
17
|
import { Clock, User } from 'lucide-react';
|
|
18
18
|
import { EmptyState } from './states';
|
|
19
19
|
import { ActorIdentity, actorInitials, actorLabel } from './ActorIdentity';
|
|
20
|
-
import type { ActorIdentityValue } from './ActorIdentity';
|
|
20
|
+
import type { ActorIdentityValue, ActorKind, ActorKindSource } from './ActorIdentity';
|
|
21
21
|
|
|
22
22
|
/** Severity of an entry, for the marker. Presentation only — never a gate. */
|
|
23
23
|
export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
|
|
@@ -37,6 +37,12 @@ export interface ActivityTimelineItem {
|
|
|
37
37
|
// --- event-stream shape -------------------------------------------------
|
|
38
38
|
/** Identity of the person behind the event (preferred over `createdBy`). */
|
|
39
39
|
actor?: ActivityTimelineActor;
|
|
40
|
+
/** Whether a person or a MACHINE did it, where the stream records it
|
|
41
|
+
* (bd startsim-g4rwo). Forwarded straight to `ActorIdentity`, so a stream
|
|
42
|
+
* that does not record it renders exactly as before — and so a stream that
|
|
43
|
+
* does cannot show a robot's write as a colleague's. */
|
|
44
|
+
actorKind?: ActorKind;
|
|
45
|
+
actorKindSource?: ActorKindSource;
|
|
40
46
|
/** Colours the marker; `activityColors[type]` still wins when supplied. */
|
|
41
47
|
tone?: ActivityTone;
|
|
42
48
|
/** Muted inline qualifier after the title, e.g. "from provisioning". */
|
|
@@ -192,9 +198,16 @@ function TimelineEntry({
|
|
|
192
198
|
<p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
|
|
193
199
|
)}
|
|
194
200
|
{activity.detail && (
|
|
195
|
-
|
|
201
|
+
// A DIV, NOT A P. `detail` is typed ReactNode and the record
|
|
202
|
+
// history panel passes a field LIST into it — and a <ul> or a
|
|
203
|
+
// <div> inside a <p> is invalid HTML, which React resolves by
|
|
204
|
+
// reparenting during hydration. That produces a hydration
|
|
205
|
+
// mismatch on every row, and neither tsc nor jsdom reports it:
|
|
206
|
+
// only a real browser does (bd startsim-g4rwo). The classes are
|
|
207
|
+
// unchanged, so every existing caller renders identically.
|
|
208
|
+
<div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
196
209
|
{activity.detail}
|
|
197
|
-
</
|
|
210
|
+
</div>
|
|
198
211
|
)}
|
|
199
212
|
</div>
|
|
200
213
|
<div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
|
|
@@ -203,7 +216,12 @@ function TimelineEntry({
|
|
|
203
216
|
</div>
|
|
204
217
|
|
|
205
218
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
206
|
-
<ActorIdentity
|
|
219
|
+
<ActorIdentity
|
|
220
|
+
actor={activity.actor}
|
|
221
|
+
showInitials={!markerIsAvatar}
|
|
222
|
+
kind={activity.actorKind}
|
|
223
|
+
kindSource={activity.actorKindSource}
|
|
224
|
+
/>
|
|
207
225
|
|
|
208
226
|
{!who && activity.createdBy && (
|
|
209
227
|
<span className="flex items-center gap-1">
|
|
@@ -16,7 +16,33 @@
|
|
|
16
16
|
* the address echoed back. Every surface that attributes something to a person
|
|
17
17
|
* renders this component rather than its own arrangement of the same two
|
|
18
18
|
* fields (rule #9).
|
|
19
|
+
*
|
|
20
|
+
* AND SINCE bd startsim-g4rwo, ONE WAY TO SAY "A MACHINE DID THIS" — the same
|
|
21
|
+
* component, because the question a reader asks is the same question and the
|
|
22
|
+
* answer has to be comparable. Pass `kind`/`kindSource`; see {@link kindMarker}
|
|
23
|
+
* for which states are decorated and the inversion that decides it. Both props
|
|
24
|
+
* are optional and omitting them renders exactly what this component rendered
|
|
25
|
+
* before they existed, which is what lets the surfaces already using it stay
|
|
26
|
+
* untouched.
|
|
27
|
+
*/
|
|
28
|
+
import { Bot } from 'lucide-react';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* WHAT the actor is — and the third value is NOT a defect (bd startsim-g4rwo,
|
|
32
|
+
* claim bd startsim-jkkn7.2).
|
|
33
|
+
*
|
|
34
|
+
* `unknown` means CENTRAL DID NOT SAY. The claim is minted at LOGIN and
|
|
35
|
+
* inherited across refreshes, so for up to the 14-day refresh expiry after
|
|
36
|
+
* rollout a GENUINE HUMAN EDIT carries no kind at all. Note precisely what is
|
|
37
|
+
* missing: the KIND, never the actor — the name and email are still there — so
|
|
38
|
+
* an unknown row can still say who, and only has to stay quiet about what.
|
|
19
39
|
*/
|
|
40
|
+
export type ActorKind = 'person' | 'machine' | 'unknown';
|
|
41
|
+
|
|
42
|
+
/** Whether {@link ActorKind} came from a verified claim or from an inference
|
|
43
|
+
* ladder over naming conventions. `inferred` is a GUESS, and a surface that
|
|
44
|
+
* says so is what makes a later cleanup possible instead of a second guess. */
|
|
45
|
+
export type ActorKindSource = 'claim' | 'inferred';
|
|
20
46
|
|
|
21
47
|
export interface ActorIdentityValue {
|
|
22
48
|
id?: string;
|
|
@@ -54,33 +80,147 @@ export function actorHandle(actor?: ActorIdentityValue): string {
|
|
|
54
80
|
return email && email !== actorLabel(actor) ? email : '';
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Whether this kind earns a visible marker, and what it should say.
|
|
85
|
+
*
|
|
86
|
+
* ── THE RULE, AND IT IS AN INVERSION OF THE OBVIOUS DESIGN ──────────────────
|
|
87
|
+
*
|
|
88
|
+
* The obvious design is three badges, one per state. It FAILS the requirement
|
|
89
|
+
* even though it renders all three states, because during the rollout window
|
|
90
|
+
* `unknown` is the COMMON case for real human edits — so a badge on every
|
|
91
|
+
* unknown row makes the whole panel look broken for two weeks on every tenant,
|
|
92
|
+
* and a reader who has been trained that the badges mean trouble stops reading
|
|
93
|
+
* the one badge that matters.
|
|
94
|
+
*
|
|
95
|
+
* So ABSENCE OF A MARKER IS THE CORRECT RENDERING FOR THE CALM CASES, and only
|
|
96
|
+
* the cases that genuinely carry information are decorated:
|
|
97
|
+
*
|
|
98
|
+
* - `machine` → ALWAYS marked, with an icon, and visually distinct
|
|
99
|
+
* from any person. This is the single most valuable
|
|
100
|
+
* thing in the epic: the repeated live failure was a
|
|
101
|
+
* machine overwriting a human, and 41 machine-written
|
|
102
|
+
* notes that could not be cleaned up afterwards
|
|
103
|
+
* "precisely because the data cannot say retrospectively
|
|
104
|
+
* which were human".
|
|
105
|
+
* - `person`/`claim` → NOTHING. A colleague edited the record; that is the
|
|
106
|
+
* unremarkable default and decorating it adds noise to
|
|
107
|
+
* the good case.
|
|
108
|
+
* - `person`/`inferred`→ marked, lightly, because the tenant GUESSED and the
|
|
109
|
+
* guess should be visible to whoever might act on it.
|
|
110
|
+
* - `unknown` → NOTHING on the row. The kind is unrecorded, which is
|
|
111
|
+
* not a fact about this edit worth shouting once per
|
|
112
|
+
* row; `RecordHistoryPanel` says it ONCE for the whole
|
|
113
|
+
* panel instead, and only when some loaded row is
|
|
114
|
+
* actually unknown.
|
|
115
|
+
*
|
|
116
|
+
* The truth is never *lost* in the quiet cases — it goes into the element's
|
|
117
|
+
* accessible title, so hover and assistive tech still report it.
|
|
118
|
+
*/
|
|
119
|
+
function kindMarker(
|
|
120
|
+
kind: ActorKind | undefined,
|
|
121
|
+
source: ActorKindSource | undefined,
|
|
122
|
+
): { label: string; icon: 'bot' | null; inferred: boolean } | null {
|
|
123
|
+
if (kind === 'machine') {
|
|
124
|
+
return { label: 'machine', icon: 'bot', inferred: source === 'inferred' };
|
|
125
|
+
}
|
|
126
|
+
if (kind === 'person' && source === 'inferred') {
|
|
127
|
+
return { label: 'person, inferred', icon: null, inferred: true };
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The full truth about the kind, for `title`/screen readers — including in the
|
|
133
|
+
* cases that render no marker, so "quiet" never means "undiscoverable". */
|
|
134
|
+
export function actorKindTitle(
|
|
135
|
+
kind: ActorKind | undefined,
|
|
136
|
+
source: ActorKindSource | undefined,
|
|
137
|
+
): string {
|
|
138
|
+
if (kind === 'machine') {
|
|
139
|
+
return source === 'claim'
|
|
140
|
+
? 'A machine made this change (declared by the sign-in service).'
|
|
141
|
+
: 'A machine made this change (inferred from the account, not declared).';
|
|
142
|
+
}
|
|
143
|
+
if (kind === 'person') {
|
|
144
|
+
return source === 'claim'
|
|
145
|
+
? 'A person made this change (declared by the sign-in service).'
|
|
146
|
+
: 'A person made this change (inferred from the account, not declared).';
|
|
147
|
+
}
|
|
148
|
+
return 'Whether a person or a machine made this change was not recorded.';
|
|
149
|
+
}
|
|
150
|
+
|
|
57
151
|
export interface ActorIdentityProps {
|
|
58
152
|
actor?: ActorIdentityValue;
|
|
59
153
|
/** Show the initials avatar. Off for a compact one-line attribution, or where
|
|
60
154
|
* the surrounding row already paints the same initials. */
|
|
61
155
|
showInitials?: boolean;
|
|
156
|
+
/** Person, machine, or not recorded. Omitted on every surface that predates
|
|
157
|
+
* the revision trail, which is why it is optional and why omitting it
|
|
158
|
+
* renders byte-identically to before. */
|
|
159
|
+
kind?: ActorKind;
|
|
160
|
+
/** Whether {@link kind} was claimed or guessed. */
|
|
161
|
+
kindSource?: ActorKindSource;
|
|
62
162
|
className?: string;
|
|
63
163
|
initialsClassName?: string;
|
|
64
164
|
handleClassName?: string;
|
|
165
|
+
kindClassName?: string;
|
|
65
166
|
}
|
|
66
167
|
|
|
67
168
|
export function ActorIdentity({
|
|
68
169
|
actor,
|
|
69
170
|
showInitials = true,
|
|
171
|
+
kind,
|
|
172
|
+
kindSource,
|
|
70
173
|
className = 'flex flex-wrap items-center gap-x-1.5',
|
|
71
174
|
initialsClassName = 'flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground',
|
|
72
175
|
handleClassName = 'text-xs text-muted-foreground/80',
|
|
176
|
+
kindClassName,
|
|
73
177
|
}: ActorIdentityProps) {
|
|
74
178
|
const label = actorLabel(actor);
|
|
75
|
-
|
|
76
|
-
|
|
179
|
+
const marker = kindMarker(kind, kindSource);
|
|
180
|
+
// An empty label used to be the signal to render nothing at all, rather than
|
|
181
|
+
// a dangling "by" — and it still is, UNLESS there is a kind worth marking. A
|
|
182
|
+
// service token can carry no email whatsoever, and dropping that row's actor
|
|
183
|
+
// entirely would hide the one fact the actor axis exists to record: that a
|
|
184
|
+
// machine, not a colleague, wrote here.
|
|
185
|
+
if (!label && !marker) return null;
|
|
186
|
+
const initials = showInitials && label ? actorInitials(actor) : '';
|
|
77
187
|
const handle = actorHandle(actor);
|
|
78
188
|
|
|
79
189
|
return (
|
|
80
|
-
<span
|
|
190
|
+
<span
|
|
191
|
+
data-testid="actor-identity"
|
|
192
|
+
data-actor-kind={kind ?? undefined}
|
|
193
|
+
data-actor-kind-source={kindSource ?? undefined}
|
|
194
|
+
className={className}
|
|
195
|
+
>
|
|
81
196
|
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
82
|
-
<span>{label}</span>
|
|
197
|
+
{label && <span>{label}</span>}
|
|
83
198
|
{handle && <span className={handleClassName}>{handle}</span>}
|
|
199
|
+
{marker && (
|
|
200
|
+
<span
|
|
201
|
+
data-testid="actor-kind"
|
|
202
|
+
// NOT a warning colour. This is a statement of fact about who typed,
|
|
203
|
+
// and the dark-tenant palette reserves red for failure; a machine
|
|
204
|
+
// edit is routine and must not read as an incident. The icon, not a
|
|
205
|
+
// colour, is what makes it scannable — and `inferred` is drawn with a
|
|
206
|
+
// DASHED border and no fill, so "we guessed" is lighter on the page
|
|
207
|
+
// than "we were told" without needing a second colour.
|
|
208
|
+
className={
|
|
209
|
+
kindClassName ??
|
|
210
|
+
[
|
|
211
|
+
'inline-flex items-center gap-1 rounded border px-1.5 py-px',
|
|
212
|
+
'text-[10px] font-medium uppercase tracking-wide',
|
|
213
|
+
marker.inferred
|
|
214
|
+
? 'border-dashed border-border text-muted-foreground'
|
|
215
|
+
: 'border-border bg-secondary text-secondary-foreground',
|
|
216
|
+
].join(' ')
|
|
217
|
+
}
|
|
218
|
+
title={actorKindTitle(kind, kindSource)}
|
|
219
|
+
>
|
|
220
|
+
{marker.icon === 'bot' && <Bot className="h-3 w-3" aria-hidden="true" />}
|
|
221
|
+
{marker.label}
|
|
222
|
+
</span>
|
|
223
|
+
)}
|
|
84
224
|
</span>
|
|
85
225
|
);
|
|
86
226
|
}
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
* startsim-768w.18.15). Renders the (up to) three decisions from the schema as
|
|
6
6
|
* compact icon buttons ✕ reject · ✓ good · ✎ edit; a click writes status AND
|
|
7
7
|
* team_verdict together (the coherent pair, same as ReviewDrawer) IN PLACE —
|
|
8
|
-
* optimistic, no drawer.
|
|
8
|
+
* optimistic, no drawer.
|
|
9
9
|
*
|
|
10
10
|
* The ✎ "edit / needs work" decision opens a small popover with a note box so the
|
|
11
11
|
* reviewer says HOW it should change — saved to the note attr alongside the
|
|
12
12
|
* status+verdict move. Meant to live in a table's trailing "Actions" column; it
|
|
13
13
|
* stops click propagation so acting never triggers the row's drawer-open.
|
|
14
14
|
*
|
|
15
|
+
* THE ACTIVE BUTTON REPORTS STATUS, NOT THE VERDICT (bd startsim-m7fdm.12) — see
|
|
16
|
+
* `activeDecisionKey` in review.ts for why, and for what that costs.
|
|
17
|
+
*
|
|
15
18
|
* Generic + client-injected: pass a CollectionClient + the record; defaults match
|
|
16
19
|
* the OGMC content schema but everything routes through resolveReviewConfig.
|
|
17
20
|
*/
|
|
@@ -26,6 +29,7 @@ import { cn } from '../../lib/utils';
|
|
|
26
29
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
27
30
|
import { writeData } from './data';
|
|
28
31
|
import {
|
|
32
|
+
activeDecisionKey,
|
|
29
33
|
applyDecisionToData,
|
|
30
34
|
fieldStr,
|
|
31
35
|
resolveReviewConfig,
|
|
@@ -69,26 +73,37 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
69
73
|
() => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
|
|
70
74
|
[cfg],
|
|
71
75
|
);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Which decision the record's STATE reflects — the button that renders active.
|
|
78
|
+
* Resolved by the shared `activeDecisionKey`, which answers off STATUS wherever
|
|
79
|
+
* the type has a status pipeline, so the AI's `team_verdict` can no longer
|
|
80
|
+
* render as a human's approval (bd startsim-m7fdm.12).
|
|
81
|
+
*/
|
|
82
|
+
const storedKey = useMemo(
|
|
83
|
+
() => activeDecisionKey(record.data, cfg, decisions),
|
|
84
|
+
[record.data, cfg, decisions],
|
|
85
|
+
);
|
|
86
|
+
/**
|
|
87
|
+
* Optimistic override, remembered against the exact record snapshot it was made
|
|
88
|
+
* on, so it DROPS the moment a refetch hands this row a new one and the buttons
|
|
89
|
+
* go back to reporting stored state.
|
|
90
|
+
*
|
|
91
|
+
* It used to be `useState(initialKey)` — an initialiser that runs once per
|
|
92
|
+
* mounted component. A table cell's component instance outlives the record in
|
|
93
|
+
* it (a refetch, a re-sort, a page change hands the same cell a different row),
|
|
94
|
+
* and the old state then showed the PREVIOUS row's decision with nothing to
|
|
95
|
+
* correct it. Deriving from the record and overlaying the click is what makes
|
|
96
|
+
* the active state follow the data it claims to describe.
|
|
97
|
+
*/
|
|
98
|
+
const [override, setOverride] = useState<{ on: unknown; key: string } | null>(null);
|
|
99
|
+
const appliedKey = override && override.on === record.data ? override.key : storedKey;
|
|
84
100
|
const [busy, setBusy] = useState<string | null>(null);
|
|
85
101
|
const [noteOpen, setNoteOpen] = useState(false);
|
|
86
102
|
const [note, setNote] = useState('');
|
|
87
103
|
|
|
88
104
|
/** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
|
|
89
105
|
async function apply(d: ReviewDecision, noteText?: string) {
|
|
90
|
-
|
|
91
|
-
setAppliedKey(d.key); // optimistic
|
|
106
|
+
setOverride({ on: record.data, key: d.key }); // optimistic
|
|
92
107
|
setBusy(d.key);
|
|
93
108
|
try {
|
|
94
109
|
// Declared names re-key the whole outgoing blob, so a decision here cannot
|
|
@@ -99,7 +114,7 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
99
114
|
onSaved?.();
|
|
100
115
|
return true;
|
|
101
116
|
} catch (err) {
|
|
102
|
-
|
|
117
|
+
setOverride(null); // roll back to what is stored
|
|
103
118
|
notify.error(err instanceof Error ? err.message : 'Could not save.');
|
|
104
119
|
return false;
|
|
105
120
|
} finally {
|
|
@@ -45,6 +45,7 @@ import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
|
45
45
|
import { declaredBlob, readData, writeData } from './data';
|
|
46
46
|
import { parseSources } from './drafts';
|
|
47
47
|
import {
|
|
48
|
+
activeDecisionKey,
|
|
48
49
|
resolveReviewConfig,
|
|
49
50
|
reviewDecisions,
|
|
50
51
|
shouldIgnoreShortcut,
|
|
@@ -125,7 +126,19 @@ function ReviewDrawerInner({
|
|
|
125
126
|
const next = index >= 0 && index + 1 < records.length ? records[index + 1] : null;
|
|
126
127
|
|
|
127
128
|
const status = fieldStr(data, cfg.statusName);
|
|
128
|
-
|
|
129
|
+
/**
|
|
130
|
+
* Which decision the record's STATE reflects — the ringed button in the sticky
|
|
131
|
+
* bar. Off STATUS, not the verdict, for the reason `activeDecisionKey` sets out
|
|
132
|
+
* (bd startsim-m7fdm.12): the drawer's ring had the same defect as the table's
|
|
133
|
+
* tick, so a reviewer opening one of the twenty topics the re-rank agent had
|
|
134
|
+
* marked `good` saw Approve ringed over a record still sitting at `suggested`.
|
|
135
|
+
*
|
|
136
|
+
* Fed the drawer's own optimistic `data`, so a decision rings instantly; and
|
|
137
|
+
* because it reads status, the manual state override below (`overrideStatus`
|
|
138
|
+
* writes status alone, no verdict) now rings the decision it amounts to
|
|
139
|
+
* instead of ringing nothing.
|
|
140
|
+
*/
|
|
141
|
+
const activeKey = useMemo(() => activeDecisionKey(data, cfg, decisions), [data, cfg, decisions]);
|
|
129
142
|
const title = fieldStr(data, cfg.titleAttr) || record.name || `#${record.id}`;
|
|
130
143
|
const summary = fieldStr(data, cfg.summaryAttr);
|
|
131
144
|
const rank = fieldStr(data, cfg.rankAttr);
|
|
@@ -339,7 +352,7 @@ function ReviewDrawerInner({
|
|
|
339
352
|
<DecisionButton
|
|
340
353
|
key={d.key}
|
|
341
354
|
decision={d}
|
|
342
|
-
active={
|
|
355
|
+
active={d.key === activeKey}
|
|
343
356
|
busy={busy === `decision:${d.key}`}
|
|
344
357
|
onClick={() => applyDecision(d)}
|
|
345
358
|
/>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
activeDecisionKey,
|
|
3
|
+
isTerminalStatus,
|
|
2
4
|
deriveReviewTransitions,
|
|
3
5
|
resolveReviewConfig,
|
|
4
6
|
reviewDecisions,
|
|
@@ -10,6 +12,7 @@ import {
|
|
|
10
12
|
fieldStr,
|
|
11
13
|
DEFAULT_VERDICTS,
|
|
12
14
|
} from '../review';
|
|
15
|
+
import type { ReviewDecision } from '../review';
|
|
13
16
|
import type { AttributeDef, EntityTypeDef } from '../types';
|
|
14
17
|
|
|
15
18
|
function enumAttr(name: string, choices: string[]): AttributeDef {
|
|
@@ -343,3 +346,149 @@ describe('holdAfter — decisions that stay on the record', () => {
|
|
|
343
346
|
expect(resolveReviewConfig(type, { holdAfter: ['approve'] }).holdAfter).toEqual(['approve']);
|
|
344
347
|
});
|
|
345
348
|
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The green tick must report the HUMAN's decision, not the AI's verdict (bd
|
|
352
|
+
* startsim-m7fdm.12).
|
|
353
|
+
*
|
|
354
|
+
* The cases are the live OGMC distribution, measured on 171 topics: suggested/-
|
|
355
|
+
* 100, written/good 31, **suggested/good 20**, rejected/bad 16, written/bad 2,
|
|
356
|
+
* written/- 1, suggested/edit 1, and ZERO at `ready`. The twenty at
|
|
357
|
+
* suggested/good are the defect — the n8n re-rank agent wrote `team_verdict`
|
|
358
|
+
* without touching status, and the table drew a darkened green tick over every
|
|
359
|
+
* one of them.
|
|
360
|
+
*/
|
|
361
|
+
describe('activeDecisionKey', () => {
|
|
362
|
+
const topicType = type([
|
|
363
|
+
enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
|
|
364
|
+
textAttr('team_verdict'),
|
|
365
|
+
textAttr('team_notes'),
|
|
366
|
+
]);
|
|
367
|
+
const topicCfg = resolveReviewConfig(topicType);
|
|
368
|
+
const topicDecisions = reviewDecisions(topicCfg);
|
|
369
|
+
const key = (data: Record<string, unknown>) => activeDecisionKey(data, topicCfg, topicDecisions);
|
|
370
|
+
|
|
371
|
+
it('a machine-set verdict alone lights NOTHING — the defect, 20 live rows', () => {
|
|
372
|
+
expect(key({ status: 'suggested', team_verdict: 'good' })).toBeNull();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('and nothing lights on the amber one either — the agent writes `edit` too', () => {
|
|
376
|
+
// The same lie one button over: "RESOLVED from edit -> good" is the agent's
|
|
377
|
+
// own prose. Needs work writes status BACK to the entry stage, so it leaves
|
|
378
|
+
// no trace status can tell apart from untouched.
|
|
379
|
+
expect(key({ status: 'suggested', team_verdict: 'edit' })).toBeNull();
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('an untouched topic lights nothing — 100 live rows, unchanged', () => {
|
|
383
|
+
expect(key({ status: 'suggested' })).toBeNull();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('approved lights Approve — the state the fix makes reachable', () => {
|
|
387
|
+
expect(key({ status: 'ready' })).toBe('approve');
|
|
388
|
+
// and it does NOT depend on the verdict agreeing, or being present at all.
|
|
389
|
+
expect(key({ status: 'ready', team_verdict: 'bad' })).toBe('approve');
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('rejected lights Reject — 16 live rows, unchanged', () => {
|
|
393
|
+
expect(key({ status: 'rejected', team_verdict: 'bad' })).toBe('reject');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('written still lights Approve — forward past approval, 34 live rows', () => {
|
|
397
|
+
// A written topic was approved; rendering it undecided would be the same lie
|
|
398
|
+
// pointing the other way, and with the State column off by default the tick
|
|
399
|
+
// is the only state a row shows.
|
|
400
|
+
expect(key({ status: 'written', team_verdict: 'good' })).toBe('approve');
|
|
401
|
+
expect(key({ status: 'written' })).toBe('approve');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('written/bad lights GREEN where it used to light red — 2 live rows', () => {
|
|
405
|
+
// A DELIBERATE, surprising-direction change. The agent re-marked two
|
|
406
|
+
// already-written topics `bad` ("RE-MARKED (was unset, and it was
|
|
407
|
+
// WRITTEN)"); status says a human approved them and the writer wrote them,
|
|
408
|
+
// and status is the fact the tick reports.
|
|
409
|
+
expect(key({ status: 'written', team_verdict: 'bad' })).toBe('approve');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('is independent of the order the caller holds its decisions in', () => {
|
|
413
|
+
const shuffled = [...topicDecisions].reverse();
|
|
414
|
+
for (const data of [
|
|
415
|
+
{ status: 'suggested', team_verdict: 'good' },
|
|
416
|
+
{ status: 'ready' },
|
|
417
|
+
{ status: 'rejected' },
|
|
418
|
+
{ status: 'written' },
|
|
419
|
+
]) {
|
|
420
|
+
expect(activeDecisionKey(data, topicCfg, shuffled)).toBe(key(data));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('ignores an undeclared status rather than guessing', () => {
|
|
425
|
+
expect(key({ status: 'queued_for_mars' })).toBeNull();
|
|
426
|
+
expect(key({})).toBeNull();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* News Item is the path this must not break: it declares NO team_verdict and
|
|
431
|
+
* passes `verdicts: []`, so it has always resolved by status and must go on
|
|
432
|
+
* doing exactly that. Its enum runs past `acceptable` into outcomes that are
|
|
433
|
+
* NOT consequences of accepting — which is why only TERMINAL stages count
|
|
434
|
+
* forward.
|
|
435
|
+
*/
|
|
436
|
+
describe('news_item (no verdict vocabulary) is untouched', () => {
|
|
437
|
+
const newsType = type([
|
|
438
|
+
enumAttr('status', [
|
|
439
|
+
'new', 'surfaced', 'acceptable', 'cited', 'ignored', 'rejected', 'preview_only',
|
|
440
|
+
]),
|
|
441
|
+
]);
|
|
442
|
+
const newsCfg = resolveReviewConfig(newsType, {
|
|
443
|
+
approveStatus: 'acceptable',
|
|
444
|
+
rejectStatus: 'rejected',
|
|
445
|
+
verdicts: [],
|
|
446
|
+
omitNeedsWork: true,
|
|
447
|
+
});
|
|
448
|
+
const newsDecisions = reviewDecisions(newsCfg);
|
|
449
|
+
const nk = (status: string) => activeDecisionKey({ status }, newsCfg, newsDecisions);
|
|
450
|
+
|
|
451
|
+
it('acceptable → Accept, rejected → Reject', () => {
|
|
452
|
+
expect(nk('acceptable')).toBe('approve');
|
|
453
|
+
expect(nk('rejected')).toBe('reject');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('the entry stage and the pre-decision stages light nothing', () => {
|
|
457
|
+
expect(nk('new')).toBeNull();
|
|
458
|
+
expect(nk('surfaced')).toBeNull();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('`ignored` / `preview_only` sit AFTER acceptable and still light nothing', () => {
|
|
462
|
+
// The forward rule is gated on a TERMINAL-reading status for exactly this:
|
|
463
|
+
// being ignored is a different outcome, not a consequence of accepting.
|
|
464
|
+
expect(nk('ignored')).toBeNull();
|
|
465
|
+
expect(nk('preview_only')).toBeNull();
|
|
466
|
+
expect(nk('cited')).toBeNull();
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('a type with no status enum at all still resolves by verdict', () => {
|
|
471
|
+
// There, nothing but the buttons themselves writes a verdict, so the verdict
|
|
472
|
+
// IS the decision. `reviewDecisions` yields none without a status pipeline,
|
|
473
|
+
// so this asserts the branch directly.
|
|
474
|
+
const cfg = resolveReviewConfig(type([textAttr('team_verdict')]));
|
|
475
|
+
const decisions: ReviewDecision[] = [
|
|
476
|
+
{ key: 'approve', label: 'Approve', status: null, verdict: 'good', tone: 'good', advance: true, shortcut: 'a', promptNote: false },
|
|
477
|
+
{ key: 'reject', label: 'Reject', status: null, verdict: 'bad', tone: 'bad', advance: true, shortcut: 'x', promptNote: false },
|
|
478
|
+
];
|
|
479
|
+
expect(activeDecisionKey({ team_verdict: 'good' }, cfg, decisions)).toBe('approve');
|
|
480
|
+
expect(activeDecisionKey({ team_verdict: 'bad' }, cfg, decisions)).toBe('reject');
|
|
481
|
+
expect(activeDecisionKey({}, cfg, decisions)).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe('isTerminalStatus', () => {
|
|
486
|
+
it('names the end-of-pipeline stages the forward rule keys off', () => {
|
|
487
|
+
for (const s of ['written', 'published', 'sent', 'live', 'closed']) {
|
|
488
|
+
expect(isTerminalStatus(s)).toBe(true);
|
|
489
|
+
}
|
|
490
|
+
for (const s of ['suggested', 'ready', 'rejected', 'ignored', 'preview_only', 'cited']) {
|
|
491
|
+
expect(isTerminalStatus(s)).toBe(false);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
});
|