@startsimpli/ui 0.4.62 → 0.4.64
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.64",
|
|
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": {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Inline per-row review actions — the fast-triage cluster for a status table (bd
|
|
5
|
+
* startsim-768w.18.15). Renders the (up to) three decisions from the schema as
|
|
6
|
+
* compact icon buttons ✕ reject · ✓ good · ✎ edit; a click writes status AND
|
|
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
|
+
* 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.
|
|
14
|
+
*
|
|
15
|
+
* Generic + client-injected: pass a CollectionClient + the record; defaults match
|
|
16
|
+
* the OGMC content schema but everything routes through resolveReviewConfig.
|
|
17
|
+
*/
|
|
18
|
+
import { useMemo, useState } from 'react';
|
|
19
|
+
import { Check, X, PencilLine } from 'lucide-react';
|
|
20
|
+
|
|
21
|
+
import { Button } from '../ui/button';
|
|
22
|
+
import { Textarea } from '../ui/textarea';
|
|
23
|
+
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
|
24
|
+
import { notify } from '../toast/use-notify';
|
|
25
|
+
import { cn } from '../../lib/utils';
|
|
26
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
|
|
27
|
+
import { toCamelKey } from './data';
|
|
28
|
+
import {
|
|
29
|
+
applyDecisionToData,
|
|
30
|
+
fieldStr,
|
|
31
|
+
resolveReviewConfig,
|
|
32
|
+
reviewDecisions,
|
|
33
|
+
type ReviewConfig,
|
|
34
|
+
type ReviewDecision,
|
|
35
|
+
type VerdictTone,
|
|
36
|
+
} from './review';
|
|
37
|
+
|
|
38
|
+
const ICON: Record<VerdictTone, typeof Check> = { good: Check, bad: X, neutral: PencilLine };
|
|
39
|
+
const ACTIVE: Record<VerdictTone, string> = {
|
|
40
|
+
good: 'bg-emerald-600 text-white border-emerald-600',
|
|
41
|
+
bad: 'bg-rose-600 text-white border-rose-600',
|
|
42
|
+
neutral: 'bg-amber-500 text-white border-amber-500',
|
|
43
|
+
};
|
|
44
|
+
const IDLE: Record<VerdictTone, string> = {
|
|
45
|
+
good: 'border-border bg-card text-emerald-600 hover:bg-emerald-50',
|
|
46
|
+
bad: 'border-border bg-card text-rose-600 hover:bg-rose-50',
|
|
47
|
+
neutral: 'border-border bg-card text-amber-600 hover:bg-amber-50',
|
|
48
|
+
};
|
|
49
|
+
const BTN = 'flex h-8 w-8 items-center justify-center rounded-md border transition disabled:opacity-50';
|
|
50
|
+
|
|
51
|
+
// Display order: ✕ reject · ✓ good · ✎ edit — a scan-left-to-right pass/fail/fix.
|
|
52
|
+
const ORDER = ['reject', 'approve', 'needs_work'];
|
|
53
|
+
|
|
54
|
+
export interface InlineReviewActionsProps {
|
|
55
|
+
client: CollectionClient;
|
|
56
|
+
type: EntityTypeDef;
|
|
57
|
+
record: EntityRecord;
|
|
58
|
+
/** Field-map + transition overrides; defaults match the OGMC content schema. */
|
|
59
|
+
config?: ReviewConfig;
|
|
60
|
+
/** Fired after a successful save so the parent can refetch/invalidate. */
|
|
61
|
+
onSaved?: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function InlineReviewActions({ client, type, record, config, onSaved }: InlineReviewActionsProps) {
|
|
65
|
+
const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
|
|
66
|
+
const decisions = useMemo(
|
|
67
|
+
() => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
|
|
68
|
+
[cfg],
|
|
69
|
+
);
|
|
70
|
+
const [verdict, setVerdict] = useState(() => fieldStr(record.data, cfg.verdictAttr));
|
|
71
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
72
|
+
const [noteOpen, setNoteOpen] = useState(false);
|
|
73
|
+
const [note, setNote] = useState('');
|
|
74
|
+
|
|
75
|
+
/** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
|
|
76
|
+
async function apply(d: ReviewDecision, noteText?: string) {
|
|
77
|
+
const prev = verdict;
|
|
78
|
+
setVerdict(d.verdict ?? ''); // optimistic
|
|
79
|
+
setBusy(d.key);
|
|
80
|
+
try {
|
|
81
|
+
const data = applyDecisionToData(record.data, d, cfg);
|
|
82
|
+
if (noteText !== undefined) {
|
|
83
|
+
const camel = toCamelKey(cfg.noteAttr);
|
|
84
|
+
const t = noteText.trim();
|
|
85
|
+
if (t) {
|
|
86
|
+
data[camel] = t;
|
|
87
|
+
if (camel !== cfg.noteAttr) delete data[cfg.noteAttr];
|
|
88
|
+
} else {
|
|
89
|
+
delete data[camel];
|
|
90
|
+
delete data[cfg.noteAttr];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await client.updateEntity(record.id, { data });
|
|
94
|
+
onSaved?.();
|
|
95
|
+
return true;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
setVerdict(prev); // roll back
|
|
98
|
+
notify.error(err instanceof Error ? err.message : 'Could not save.');
|
|
99
|
+
return false;
|
|
100
|
+
} finally {
|
|
101
|
+
setBusy(null);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function iconButton(d: ReviewDecision, extraOnClick?: () => void) {
|
|
106
|
+
const Icon = ICON[d.tone];
|
|
107
|
+
const active = !!d.verdict && d.verdict === verdict;
|
|
108
|
+
return (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
title={d.label}
|
|
112
|
+
aria-label={d.label}
|
|
113
|
+
aria-pressed={active}
|
|
114
|
+
disabled={busy === d.key}
|
|
115
|
+
onClick={(e) => {
|
|
116
|
+
e.stopPropagation();
|
|
117
|
+
if (extraOnClick) extraOnClick();
|
|
118
|
+
else void apply(d);
|
|
119
|
+
}}
|
|
120
|
+
className={cn(BTN, active ? ACTIVE[d.tone] : IDLE[d.tone])}
|
|
121
|
+
>
|
|
122
|
+
<Icon className="h-4 w-4" />
|
|
123
|
+
</button>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (decisions.length === 0) return null;
|
|
128
|
+
return (
|
|
129
|
+
<div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
|
|
130
|
+
{decisions.map((d) =>
|
|
131
|
+
d.promptNote ? (
|
|
132
|
+
// ✎ edit: open a note box for HOW it should change, then save note + move.
|
|
133
|
+
<Popover key={d.key} open={noteOpen} onOpenChange={setNoteOpen}>
|
|
134
|
+
<PopoverTrigger asChild>
|
|
135
|
+
{iconButton(d, () => setNote(fieldStr(record.data, cfg.noteAttr)))}
|
|
136
|
+
</PopoverTrigger>
|
|
137
|
+
<PopoverContent align="end" className="w-72" onClick={(e) => e.stopPropagation()}>
|
|
138
|
+
<p className="mb-1.5 text-xs font-medium text-foreground">What should change?</p>
|
|
139
|
+
<Textarea
|
|
140
|
+
value={note}
|
|
141
|
+
onChange={(e) => setNote(e.target.value)}
|
|
142
|
+
rows={3}
|
|
143
|
+
autoFocus
|
|
144
|
+
placeholder="e.g. angle too broad; needs a 2026 source"
|
|
145
|
+
className="text-sm"
|
|
146
|
+
/>
|
|
147
|
+
<div className="mt-2 flex justify-end gap-2">
|
|
148
|
+
<Button
|
|
149
|
+
size="sm"
|
|
150
|
+
variant="outline"
|
|
151
|
+
onClick={() => setNoteOpen(false)}
|
|
152
|
+
disabled={busy === d.key}
|
|
153
|
+
>
|
|
154
|
+
Cancel
|
|
155
|
+
</Button>
|
|
156
|
+
<Button
|
|
157
|
+
size="sm"
|
|
158
|
+
onClick={() => {
|
|
159
|
+
void apply(d, note).then((ok) => ok && setNoteOpen(false));
|
|
160
|
+
}}
|
|
161
|
+
disabled={busy === d.key}
|
|
162
|
+
>
|
|
163
|
+
{busy === d.key ? 'Saving…' : 'Save'}
|
|
164
|
+
</Button>
|
|
165
|
+
</div>
|
|
166
|
+
</PopoverContent>
|
|
167
|
+
</Popover>
|
|
168
|
+
) : (
|
|
169
|
+
<span key={d.key}>{iconButton(d)}</span>
|
|
170
|
+
),
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
deriveReviewTransitions,
|
|
3
3
|
resolveReviewConfig,
|
|
4
4
|
reviewDecisions,
|
|
5
|
+
applyDecisionToData,
|
|
5
6
|
pickStatusAttr,
|
|
6
7
|
shouldIgnoreShortcut,
|
|
7
8
|
nextReviewIndex,
|
|
@@ -139,6 +140,34 @@ describe('reviewDecisions', () => {
|
|
|
139
140
|
});
|
|
140
141
|
});
|
|
141
142
|
|
|
143
|
+
describe('applyDecisionToData', () => {
|
|
144
|
+
const topic = type([
|
|
145
|
+
enumAttr('status', ['suggested', 'ready', 'rejected', 'written']),
|
|
146
|
+
textAttr('team_verdict'),
|
|
147
|
+
]);
|
|
148
|
+
const cfg = resolveReviewConfig(topic);
|
|
149
|
+
const decisions = reviewDecisions(cfg);
|
|
150
|
+
const byKey = (k: string) => decisions.find((d) => d.key === k)!;
|
|
151
|
+
|
|
152
|
+
it('writes status + verdict together (camelCased), preserving other keys', () => {
|
|
153
|
+
const out = applyDecisionToData({ title: 'T', status: 'suggested', teamVerdict: '' }, byKey('approve'), cfg);
|
|
154
|
+
expect(out.status).toBe('ready');
|
|
155
|
+
expect(out.teamVerdict).toBe('good');
|
|
156
|
+
expect(out.title).toBe('T');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reject writes rejected + bad', () => {
|
|
160
|
+
const out = applyDecisionToData({}, byKey('reject'), cfg);
|
|
161
|
+
expect(out).toEqual({ status: 'rejected', teamVerdict: 'bad' });
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('drops a snake-case duplicate of a camelCased key', () => {
|
|
165
|
+
const out = applyDecisionToData({ team_verdict: 'old' }, byKey('approve'), cfg);
|
|
166
|
+
expect(out.teamVerdict).toBe('good');
|
|
167
|
+
expect(out.team_verdict).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
142
171
|
describe('shouldIgnoreShortcut', () => {
|
|
143
172
|
it('ignores while typing in a form control', () => {
|
|
144
173
|
expect(shouldIgnoreShortcut({ key: 'a', target: { tagName: 'INPUT' } })).toBe(true);
|
|
@@ -8,10 +8,12 @@
|
|
|
8
8
|
export { TopicReviewWorkspace, type TopicReviewWorkspaceProps } from './TopicReviewWorkspace';
|
|
9
9
|
export { DraftReviewWorkspace, type DraftReviewWorkspaceProps } from './DraftReviewWorkspace';
|
|
10
10
|
export { ReviewDrawer, type ReviewDrawerProps } from './ReviewDrawer';
|
|
11
|
+
export { InlineReviewActions, type InlineReviewActionsProps } from './InlineReviewActions';
|
|
11
12
|
export {
|
|
12
13
|
resolveReviewConfig,
|
|
13
14
|
deriveReviewTransitions,
|
|
14
15
|
reviewDecisions,
|
|
16
|
+
applyDecisionToData,
|
|
15
17
|
type ReviewDecision,
|
|
16
18
|
pickStatusAttr,
|
|
17
19
|
shouldIgnoreShortcut,
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* Nothing here is tenant-specific — the defaults are conventions, not requirements.
|
|
16
16
|
*/
|
|
17
17
|
import type { AttributeDef, EntityTypeDef } from './types';
|
|
18
|
-
import { readData } from './data';
|
|
18
|
+
import { readData, toCamelKey } from './data';
|
|
19
19
|
|
|
20
20
|
// ---- keyboard guard (lifted so the drawer + any review surface share it) ----
|
|
21
21
|
|
|
@@ -297,6 +297,29 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
|
|
|
297
297
|
return out;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Merge a decision's status + verdict into a record's (camelCased) data blob and
|
|
302
|
+
* return the FULL next blob — the backend PATCH replaces `data`, so callers must
|
|
303
|
+
* send the whole thing. Pure, so both the drawer and the inline row actions apply
|
|
304
|
+
* a decision identically (and it's unit-tested). Null status/verdict are skipped.
|
|
305
|
+
*/
|
|
306
|
+
export function applyDecisionToData(
|
|
307
|
+
data: Record<string, unknown> | undefined,
|
|
308
|
+
decision: ReviewDecision,
|
|
309
|
+
cfg: ResolvedReview,
|
|
310
|
+
): Record<string, unknown> {
|
|
311
|
+
const next: Record<string, unknown> = { ...(data ?? {}) };
|
|
312
|
+
const set = (attr: string, value: string | null) => {
|
|
313
|
+
if (value == null || value === '') return;
|
|
314
|
+
const camel = toCamelKey(attr);
|
|
315
|
+
next[camel] = value;
|
|
316
|
+
if (camel !== attr) delete next[attr];
|
|
317
|
+
};
|
|
318
|
+
set(cfg.statusName, decision.status);
|
|
319
|
+
set(cfg.verdictAttr, decision.verdict);
|
|
320
|
+
return next;
|
|
321
|
+
}
|
|
322
|
+
|
|
300
323
|
// ---- small read helpers used by the component (kept here so they're tested) ----
|
|
301
324
|
|
|
302
325
|
/** String read of an attr off a record's data blob ('' when missing). */
|