@startsimpli/ui 0.4.63 → 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.63",
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/api": "0.5.44",
46
- "@startsimpli/auth": "0.4.30"
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. Meant to
9
- * live in a table's trailing "Actions" column; it stops click propagation so
10
- * acting never triggers the row's drawer-open.
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'];
@@ -61,49 +69,106 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
61
69
  );
62
70
  const [verdict, setVerdict] = useState(() => fieldStr(record.data, cfg.verdictAttr));
63
71
  const [busy, setBusy] = useState<string | null>(null);
72
+ const [noteOpen, setNoteOpen] = useState(false);
73
+ const [note, setNote] = useState('');
64
74
 
65
- async function apply(d: ReviewDecision) {
75
+ /** Apply a decision (status + verdict), optionally attaching a note. Optimistic. */
76
+ async function apply(d: ReviewDecision, noteText?: string) {
66
77
  const prev = verdict;
67
78
  setVerdict(d.verdict ?? ''); // optimistic
68
79
  setBusy(d.key);
69
80
  try {
70
- await client.updateEntity(record.id, { data: applyDecisionToData(record.data, d, cfg) });
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 });
71
94
  onSaved?.();
95
+ return true;
72
96
  } catch (err) {
73
97
  setVerdict(prev); // roll back
74
98
  notify.error(err instanceof Error ? err.message : 'Could not save.');
99
+ return false;
75
100
  } finally {
76
101
  setBusy(null);
77
102
  }
78
103
  }
79
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
+
80
127
  if (decisions.length === 0) return null;
81
128
  return (
82
129
  <div className="flex items-center justify-end gap-1" onClick={(e) => e.stopPropagation()}>
83
- {decisions.map((d) => {
84
- const Icon = ICON[d.tone];
85
- const active = !!d.verdict && d.verdict === verdict;
86
- return (
87
- <button
88
- key={d.key}
89
- type="button"
90
- title={d.label}
91
- aria-label={d.label}
92
- aria-pressed={active}
93
- disabled={busy === d.key}
94
- onClick={(e) => {
95
- e.stopPropagation();
96
- void apply(d);
97
- }}
98
- className={cn(
99
- 'flex h-8 w-8 items-center justify-center rounded-md border transition disabled:opacity-50',
100
- active ? ACTIVE[d.tone] : IDLE[d.tone],
101
- )}
102
- >
103
- <Icon className="h-4 w-4" />
104
- </button>
105
- );
106
- })}
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
+ )}
107
172
  </div>
108
173
  );
109
174
  }