@startsimpli/ui 0.4.63 → 0.4.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.65",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
43
43
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
44
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
45
|
-
"@startsimpli/
|
|
46
|
-
"@startsimpli/
|
|
45
|
+
"@startsimpli/auth": "0.4.30",
|
|
46
|
+
"@startsimpli/api": "0.5.44"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"@startsimpli/api": {
|
|
@@ -5,9 +5,12 @@
|
|
|
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. The current verdict shows as the active button.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* optimistic, no drawer. The current verdict shows as the active button.
|
|
9
|
+
*
|
|
10
|
+
* The ✎ "edit / needs work" decision opens a small popover with a note box so the
|
|
11
|
+
* reviewer says HOW it should change — saved to the note attr alongside the
|
|
12
|
+
* status+verdict move. Meant to live in a table's trailing "Actions" column; it
|
|
13
|
+
* stops click propagation so acting never triggers the row's drawer-open.
|
|
11
14
|
*
|
|
12
15
|
* Generic + client-injected: pass a CollectionClient + the record; defaults match
|
|
13
16
|
* the OGMC content schema but everything routes through resolveReviewConfig.
|
|
@@ -15,9 +18,13 @@
|
|
|
15
18
|
import { useMemo, useState } from 'react';
|
|
16
19
|
import { Check, X, PencilLine } from 'lucide-react';
|
|
17
20
|
|
|
21
|
+
import { Button } from '../ui/button';
|
|
22
|
+
import { Textarea } from '../ui/textarea';
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
18
24
|
import { notify } from '../toast/use-notify';
|
|
19
25
|
import { cn } from '../../lib/utils';
|
|
20
26
|
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
27
|
+
import { toCamelKey } from './data';
|
|
21
28
|
import {
|
|
22
29
|
applyDecisionToData,
|
|
23
30
|
fieldStr,
|
|
@@ -39,6 +46,7 @@ const IDLE: Record<VerdictTone, string> = {
|
|
|
39
46
|
bad: 'border-border bg-card text-rose-600 hover:bg-rose-50',
|
|
40
47
|
neutral: 'border-border bg-card text-amber-600 hover:bg-amber-50',
|
|
41
48
|
};
|
|
49
|
+
const BTN = 'flex h-8 w-8 items-center justify-center rounded-md border transition disabled:opacity-50';
|
|
42
50
|
|
|
43
51
|
// Display order: ✕ reject · ✓ good · ✎ edit — a scan-left-to-right pass/fail/fix.
|
|
44
52
|
const ORDER = ['reject', 'approve', 'needs_work'];
|
|
@@ -59,51 +67,119 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
|
|
|
59
67
|
() => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
|
|
60
68
|
[cfg],
|
|
61
69
|
);
|
|
62
|
-
|
|
70
|
+
// Which decision the record currently reflects — by verdict when the type has
|
|
71
|
+
// one (topics), else by status (news: acceptable→approve, rejected→reject). The
|
|
72
|
+
// matching button shows active. Optimistically updated on click.
|
|
73
|
+
const initialKey = useMemo(() => {
|
|
74
|
+
const v = fieldStr(record.data, cfg.verdictAttr);
|
|
75
|
+
const s = fieldStr(record.data, cfg.statusName);
|
|
76
|
+
return (
|
|
77
|
+
decisions.find((d) => (d.verdict ? d.verdict === v : d.status ? d.status === s : false))?.key ??
|
|
78
|
+
null
|
|
79
|
+
);
|
|
80
|
+
}, [record.data, cfg, decisions]);
|
|
81
|
+
const [appliedKey, setAppliedKey] = useState<string | null>(initialKey);
|
|
63
82
|
const [busy, setBusy] = useState<string | null>(null);
|
|
83
|
+
const [noteOpen, setNoteOpen] = useState(false);
|
|
84
|
+
const [note, setNote] = useState('');
|
|
64
85
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
86
|
+
/** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
|
|
87
|
+
async function apply(d: ReviewDecision, noteText?: string) {
|
|
88
|
+
const prev = appliedKey;
|
|
89
|
+
setAppliedKey(d.key); // optimistic
|
|
68
90
|
setBusy(d.key);
|
|
69
91
|
try {
|
|
70
|
-
|
|
92
|
+
const data = applyDecisionToData(record.data, d, cfg);
|
|
93
|
+
if (noteText !== undefined) {
|
|
94
|
+
const camel = toCamelKey(cfg.noteAttr);
|
|
95
|
+
const t = noteText.trim();
|
|
96
|
+
if (t) {
|
|
97
|
+
data[camel] = t;
|
|
98
|
+
if (camel !== cfg.noteAttr) delete data[cfg.noteAttr];
|
|
99
|
+
} else {
|
|
100
|
+
delete data[camel];
|
|
101
|
+
delete data[cfg.noteAttr];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await client.updateEntity(record.id, { data });
|
|
71
105
|
onSaved?.();
|
|
106
|
+
return true;
|
|
72
107
|
} catch (err) {
|
|
73
|
-
|
|
108
|
+
setAppliedKey(prev); // roll back
|
|
74
109
|
notify.error(err instanceof Error ? err.message : 'Could not save.');
|
|
110
|
+
return false;
|
|
75
111
|
} finally {
|
|
76
112
|
setBusy(null);
|
|
77
113
|
}
|
|
78
114
|
}
|
|
79
115
|
|
|
116
|
+
function iconButton(d: ReviewDecision, extraOnClick?: () => void) {
|
|
117
|
+
const Icon = ICON[d.tone];
|
|
118
|
+
const active = d.key === appliedKey;
|
|
119
|
+
return (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
title={d.label}
|
|
123
|
+
aria-label={d.label}
|
|
124
|
+
aria-pressed={active}
|
|
125
|
+
disabled={busy === d.key}
|
|
126
|
+
onClick={(e) => {
|
|
127
|
+
e.stopPropagation();
|
|
128
|
+
if (extraOnClick) extraOnClick();
|
|
129
|
+
else void apply(d);
|
|
130
|
+
}}
|
|
131
|
+
className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone])}
|
|
132
|
+
>
|
|
133
|
+
<Icon className="h-4 w-4" />
|
|
134
|
+
</button>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
80
138
|
if (decisions.length === 0) return null;
|
|
81
139
|
return (
|
|
82
140
|
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
|
83
|
-
{decisions.map((d) =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
141
|
+
{decisions.map((d) =>
|
|
142
|
+
d.promptNote ? (
|
|
143
|
+
// ✎ edit: open a note box for HOW it should change, then save note + move.
|
|
144
|
+
<Popover key={d.key} open={noteOpen} onOpenChange={setNoteOpen}>
|
|
145
|
+
<PopoverTrigger asChild>
|
|
146
|
+
{iconButton(d, () => setNote(fieldStr(record.data, cfg.noteAttr)))}
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
<PopoverContent align="end" className="w-72" onClick={(e) => e.stopPropagation()}>
|
|
149
|
+
<p className="mb-1.5 text-xs font-medium text-foreground">What should change?</p>
|
|
150
|
+
<Textarea
|
|
151
|
+
value={note}
|
|
152
|
+
onChange={(e) => setNote(e.target.value)}
|
|
153
|
+
rows={3}
|
|
154
|
+
autoFocus
|
|
155
|
+
placeholder="e.g. angle too broad; needs a 2026 source"
|
|
156
|
+
className="text-sm"
|
|
157
|
+
/>
|
|
158
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
159
|
+
<Button
|
|
160
|
+
size="sm"
|
|
161
|
+
variant="outline"
|
|
162
|
+
onClick={() => setNoteOpen(false)}
|
|
163
|
+
disabled={busy === d.key}
|
|
164
|
+
>
|
|
165
|
+
Cancel
|
|
166
|
+
</Button>
|
|
167
|
+
<Button
|
|
168
|
+
size="sm"
|
|
169
|
+
onClick={() => {
|
|
170
|
+
void apply(d, note).then((ok) => ok && setNoteOpen(false));
|
|
171
|
+
}}
|
|
172
|
+
disabled={busy === d.key}
|
|
173
|
+
>
|
|
174
|
+
{busy === d.key ? 'Saving…' : 'Save'}
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
</PopoverContent>
|
|
178
|
+
</Popover>
|
|
179
|
+
) : (
|
|
180
|
+
<span key={d.key}>{iconButton(d)}</span>
|
|
181
|
+
),
|
|
182
|
+
)}
|
|
107
183
|
</div>
|
|
108
184
|
);
|
|
109
185
|
}
|
|
@@ -124,6 +124,21 @@ describe('reviewDecisions', () => {
|
|
|
124
124
|
expect(keys).not.toContain('reject');
|
|
125
125
|
});
|
|
126
126
|
|
|
127
|
+
it('omitNeedsWork + custom transitions gives a binary accept/reject (news curation)', () => {
|
|
128
|
+
const news = type([enumAttr('status', ['new', 'surfaced', 'acceptable', 'rejected'])]);
|
|
129
|
+
const d = reviewDecisions(
|
|
130
|
+
resolveReviewConfig(news, {
|
|
131
|
+
approveStatus: 'acceptable',
|
|
132
|
+
rejectStatus: 'rejected',
|
|
133
|
+
verdicts: [],
|
|
134
|
+
omitNeedsWork: true,
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
expect(d.map((x) => x.key).sort()).toEqual(['approve', 'reject']);
|
|
138
|
+
expect(d.find((x) => x.key === 'approve')).toMatchObject({ status: 'acceptable', verdict: null });
|
|
139
|
+
expect(d.find((x) => x.key === 'reject')).toMatchObject({ status: 'rejected', verdict: null });
|
|
140
|
+
});
|
|
141
|
+
|
|
127
142
|
it('uses custom verdict vocab tones', () => {
|
|
128
143
|
const d = reviewDecisions(
|
|
129
144
|
resolveReviewConfig(topic, {
|
|
@@ -167,6 +167,8 @@ export interface ReviewConfig {
|
|
|
167
167
|
metaAttrs?: string[];
|
|
168
168
|
/** Attrs holding source urls/lines (default: source_1..3 + "sources"). */
|
|
169
169
|
sourceAttrs?: string[];
|
|
170
|
+
/** Drop the middle "Needs work" decision — a binary accept/reject flow (default false). */
|
|
171
|
+
omitNeedsWork?: boolean;
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
export interface ResolvedReview {
|
|
@@ -182,6 +184,7 @@ export interface ResolvedReview {
|
|
|
182
184
|
rankAttr: string;
|
|
183
185
|
metaAttrs: string[];
|
|
184
186
|
sourceAttrs: string[];
|
|
187
|
+
omitNeedsWork: boolean;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
|
|
@@ -212,6 +215,7 @@ export function resolveReviewConfig(
|
|
|
212
215
|
metaAttrs: (config.metaAttrs ?? DEFAULT_META_ATTRS).filter((a) => declared.has(a)),
|
|
213
216
|
// Keep source attrs the caller asked for; default to the ones this type declares.
|
|
214
217
|
sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
|
|
218
|
+
omitNeedsWork: config.omitNeedsWork ?? false,
|
|
215
219
|
};
|
|
216
220
|
}
|
|
217
221
|
|
|
@@ -269,7 +273,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
269
273
|
}
|
|
270
274
|
|
|
271
275
|
const editVerdict = verdictOf('neutral');
|
|
272
|
-
if (entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
276
|
+
if (!r.omitNeedsWork && entry && (editVerdict || entry !== r.transitions.approve)) {
|
|
273
277
|
out.push({
|
|
274
278
|
key: 'needs_work',
|
|
275
279
|
label: 'Needs work',
|