@startsimpli/ui 0.4.155 → 0.4.157

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.155",
3
+ "version": "0.4.157",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -55,9 +55,9 @@
55
55
  "@codemirror/language": "^6.0.0",
56
56
  "@codemirror/state": "^6.0.0",
57
57
  "@codemirror/view": "^6.0.0",
58
- "@startsimpli/api": "^0.5.68",
59
- "@startsimpli/auth": "^0.4.41",
60
- "@startsimpli/i18n": "^0.1.3"
58
+ "@startsimpli/auth": "^0.4.42",
59
+ "@startsimpli/i18n": "^0.1.3",
60
+ "@startsimpli/api": "^0.5.68"
61
61
  },
62
62
  "peerDependenciesMeta": {
63
63
  "@startsimpli/api": {
@@ -152,8 +152,8 @@
152
152
  "tailwindcss": "^3.4.19",
153
153
  "tsup": "^8.5.1",
154
154
  "typescript": "^6.0.3",
155
- "@startsimpli/realtime": "0.0.2",
156
- "@startsimpli/i18n": "0.1.3"
155
+ "@startsimpli/i18n": "0.1.3",
156
+ "@startsimpli/realtime": "0.0.2"
157
157
  },
158
158
  "keywords": [
159
159
  "ui",
@@ -92,6 +92,16 @@ export interface ReviewDrawerProps {
92
92
  * see the decision that was just made.
93
93
  */
94
94
  renderExtra?: (record: EntityRecord, type: EntityTypeDef) => ReactNode;
95
+ /**
96
+ * False for a reader who cannot change this record (bd startsim-m7fdm.32).
97
+ * The record, its sources, its note and prev/next stay; the State select,
98
+ * "Edit fields", the decisions, "Add note" and their shortcuts are not drawn,
99
+ * so a view-only reader is not offered a control that can only end in
100
+ * "Could not save.". A control the person cannot use is hidden, not greyed
101
+ * out. The host decides it per record (a share on the record answers).
102
+ * Defaults to true.
103
+ */
104
+ canEdit?: boolean;
95
105
  }
96
106
 
97
107
  export function ReviewDrawer(props: ReviewDrawerProps) {
@@ -111,6 +121,7 @@ function ReviewDrawerInner({
111
121
  sourceTierByHost,
112
122
  renderEditFields,
113
123
  renderExtra,
124
+ canEdit = true,
114
125
  }: ReviewDrawerProps & { record: EntityRecord }) {
115
126
  const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
116
127
  const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
@@ -229,7 +240,7 @@ function ReviewDrawerInner({
229
240
  if (mode !== 'review') return;
230
241
  function onKey(e: KeyboardEvent) {
231
242
  if (shouldIgnoreShortcut(e)) return;
232
- const d = decisions.find((x) => x.shortcut === e.key);
243
+ const d = canEdit ? decisions.find((x) => x.shortcut === e.key) : undefined;
233
244
  if (d) {
234
245
  e.preventDefault();
235
246
  applyDecision(d);
@@ -244,16 +255,20 @@ function ReviewDrawerInner({
244
255
  case 'ArrowUp':
245
256
  if (prev) { e.preventDefault(); onNavigate(prev); }
246
257
  break;
247
- case 'e': e.preventDefault(); setMode('edit'); break;
258
+ case 'e':
259
+ if (canEdit) { e.preventDefault(); setMode('edit'); }
260
+ break;
248
261
  case 'Escape': e.preventDefault(); onClose(); break;
249
262
  }
250
263
  }
251
264
  window.addEventListener('keydown', onKey);
252
265
  return () => window.removeEventListener('keydown', onKey);
253
266
  // eslint-disable-next-line react-hooks/exhaustive-deps
254
- }, [mode, prev, next, decisions, data]);
267
+ }, [mode, prev, next, decisions, data, canEdit]);
255
268
 
256
- const legend = [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ');
269
+ const legend = canEdit
270
+ ? [...decisions.map((d) => `${d.shortcut} ${d.label.toLowerCase()}`), 'e edit', 'j/k move'].join(' · ')
271
+ : 'j/k move';
257
272
 
258
273
  return (
259
274
  <div className="fixed inset-0 z-50 flex justify-end" role="dialog" aria-modal="true">
@@ -270,7 +285,13 @@ function ReviewDrawerInner({
270
285
  </div>
271
286
  <div className="flex shrink-0 items-center gap-1">
272
287
  {/* Current state — and the rare manual override (mark written, un-reject). */}
273
- {cfg.statusChoices.length > 0 ? (
288
+ {!canEdit ? (
289
+ status ? (
290
+ <span className="rounded-full bg-muted px-2 py-0.5 text-xs capitalize text-muted-foreground">
291
+ {status.replace(/_/g, ' ')}
292
+ </span>
293
+ ) : null
294
+ ) : cfg.statusChoices.length > 0 ? (
274
295
  <Select value={status || undefined} onValueChange={overrideStatus}>
275
296
  <SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
276
297
  <SelectValue placeholder="State…" />
@@ -302,13 +323,15 @@ function ReviewDrawerInner({
302
323
  >
303
324
  <ChevronRight className="h-4 w-4" />
304
325
  </button>
305
- <button
306
- onClick={() => setMode((m) => (m === 'review' ? 'edit' : 'review'))}
307
- className="ml-1 rounded border border-border px-2.5 py-1 text-xs hover:bg-muted"
308
- title="Edit fields (e)"
309
- >
310
- {mode === 'review' ? 'Edit fields' : 'Review'}
311
- </button>
326
+ {canEdit ? (
327
+ <button
328
+ onClick={() => setMode((m) => (m === 'review' ? 'edit' : 'review'))}
329
+ className="ml-1 rounded border border-border px-2.5 py-1 text-xs hover:bg-muted"
330
+ title="Edit fields (e)"
331
+ >
332
+ {mode === 'review' ? 'Edit fields' : 'Review'}
333
+ </button>
334
+ ) : null}
312
335
  <button
313
336
  onClick={onClose}
314
337
  className="rounded p-1 text-muted-foreground hover:bg-muted"
@@ -319,7 +342,7 @@ function ReviewDrawerInner({
319
342
  </div>
320
343
  </header>
321
344
 
322
- {mode === 'edit' ? (
345
+ {mode === 'edit' && canEdit ? (
323
346
  <div className="flex-1 overflow-y-auto px-4 py-4">
324
347
  {renderEditFields ? (
325
348
  renderEditFields({
@@ -374,7 +397,7 @@ function ReviewDrawerInner({
374
397
  </div>
375
398
 
376
399
  <footer className="space-y-3 border-t border-border px-4 py-3">
377
- {decisions.length > 0 ? (
400
+ {canEdit && decisions.length > 0 ? (
378
401
  <div className="flex items-stretch gap-2">
379
402
  {decisions.map((d) => (
380
403
  <DecisionButton
@@ -389,20 +412,24 @@ function ReviewDrawerInner({
389
412
  ) : null}
390
413
 
391
414
  <div className="flex items-center gap-2">
392
- <button
393
- onClick={() => setNoteOpen((o) => !o)}
394
- className={cn(
395
- 'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
396
- note && 'text-primary',
397
- )}
398
- >
399
- <MessageSquarePlus className="h-3.5 w-3.5" />
400
- {note ? 'Note ✓' : 'Add note'}
401
- </button>
415
+ {canEdit ? (
416
+ <button
417
+ onClick={() => setNoteOpen((o) => !o)}
418
+ className={cn(
419
+ 'inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-xs hover:bg-muted',
420
+ note && 'text-primary',
421
+ )}
422
+ >
423
+ <MessageSquarePlus className="h-3.5 w-3.5" />
424
+ {note ? 'Note ✓' : 'Add note'}
425
+ </button>
426
+ ) : (
427
+ <span className="text-xs text-muted-foreground">You can view this {type.label.toLowerCase()}.</span>
428
+ )}
402
429
  <span className="ml-auto text-[11px] text-muted-foreground">{legend}</span>
403
430
  </div>
404
431
 
405
- {noteOpen ? (
432
+ {noteOpen && canEdit ? (
406
433
  <div className="space-y-2">
407
434
  <Textarea
408
435
  value={note}
@@ -0,0 +1,119 @@
1
+ /**
2
+ * A reader who cannot change the record is not offered controls that can only
3
+ * end in "Could not save." (bd startsim-m7fdm.32, review bead startsim-j9agp).
4
+ *
5
+ * Before this, the drawer drew the State select, "Edit fields", Approve /
6
+ * Needs work / Reject and "Add note" for everyone, and a view-only reader
7
+ * learned the truth from an error toast after pressing one. The host now says
8
+ * per record whether it may be changed, and the drawer draws accordingly.
9
+ */
10
+ import * as React from 'react';
11
+ import { fireEvent, render, screen } from '@testing-library/react';
12
+
13
+ import { ReviewDrawer } from '../ReviewDrawer';
14
+ import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
15
+
16
+ const TOPIC_TYPE: EntityTypeDef = {
17
+ id: 1,
18
+ key: 'topic',
19
+ label: 'Topic',
20
+ attributes: [
21
+ {
22
+ id: 'status',
23
+ name: 'status',
24
+ dataType: 'enum',
25
+ required: false,
26
+ config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
27
+ },
28
+ { id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
29
+ { id: 'team_notes', name: 'team_notes', dataType: 'text', required: false, config: {} },
30
+ ],
31
+ };
32
+
33
+ function topic(id: number, title: string, note = ''): EntityRecord {
34
+ return {
35
+ id,
36
+ entityType: 'topic',
37
+ externalId: null,
38
+ name: title,
39
+ data: { title, status: 'suggested', ...(note ? { team_notes: note } : {}) },
40
+ createdAt: '2026-09-01T00:00:00Z',
41
+ };
42
+ }
43
+
44
+ function page<T>(results: T[]): Paginated<T> {
45
+ return { count: results.length, next: null, previous: null, results };
46
+ }
47
+
48
+ function stubClient(): CollectionClient & { updateEntity: jest.Mock } {
49
+ const updateEntity = jest.fn(async () => topic(1, 'x'));
50
+ return {
51
+ listTypes: jest.fn(async () => page([TOPIC_TYPE])),
52
+ listAllEntities: jest.fn(async () => []),
53
+ updateEntity,
54
+ } as CollectionClient & { updateEntity: jest.Mock };
55
+ }
56
+
57
+ function renderDrawer(canEdit: boolean | undefined, record = topic(1, 'Oman customs')) {
58
+ const client = stubClient();
59
+ const onNavigate = jest.fn();
60
+ const records = [record, topic(2, 'Bahrain filing')];
61
+ render(
62
+ <ReviewDrawer
63
+ client={client}
64
+ type={TOPIC_TYPE}
65
+ records={records}
66
+ record={record}
67
+ onClose={() => {}}
68
+ onNavigate={onNavigate}
69
+ renderEditFields={() => <div>FIELD EDITOR</div>}
70
+ {...(canEdit === undefined ? {} : { canEdit })}
71
+ />,
72
+ );
73
+ return { client, onNavigate };
74
+ }
75
+
76
+ describe('ReviewDrawer for a view-only reader', () => {
77
+ it('draws no State select, Edit fields, decisions or Add note', () => {
78
+ renderDrawer(false);
79
+
80
+ expect(screen.getByText('Oman customs')).toBeInTheDocument();
81
+ expect(screen.queryByRole('combobox')).toBeNull();
82
+ expect(screen.queryByRole('button', { name: /edit fields/i })).toBeNull();
83
+ expect(screen.queryByRole('button', { name: /approve/i })).toBeNull();
84
+ expect(screen.queryByRole('button', { name: /reject/i })).toBeNull();
85
+ expect(screen.queryByRole('button', { name: /needs work/i })).toBeNull();
86
+ expect(screen.queryByRole('button', { name: /add note/i })).toBeNull();
87
+ expect(screen.getByText('You can view this topic.')).toBeInTheDocument();
88
+ });
89
+
90
+ it('still shows where the record stands, and its note', () => {
91
+ renderDrawer(false, topic(1, 'Oman customs', 'Angle too broad.'));
92
+
93
+ expect(screen.getByText('suggested')).toBeInTheDocument();
94
+ expect(screen.getByText('Angle too broad.')).toBeInTheDocument();
95
+ });
96
+
97
+ it('ignores the decision and edit shortcuts, and still walks the queue', () => {
98
+ const { client, onNavigate } = renderDrawer(false);
99
+
100
+ fireEvent.keyDown(window, { key: 'a' });
101
+ fireEvent.keyDown(window, { key: 'x' });
102
+ fireEvent.keyDown(window, { key: 'e' });
103
+ expect(client.updateEntity).not.toHaveBeenCalled();
104
+ expect(screen.queryByText('FIELD EDITOR')).toBeNull();
105
+
106
+ fireEvent.keyDown(window, { key: 'j' });
107
+ expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ id: 2 }));
108
+ });
109
+
110
+ it('an editor still gets every control (the default)', () => {
111
+ renderDrawer(undefined);
112
+
113
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
114
+ expect(screen.getByRole('button', { name: /edit fields/i })).toBeInTheDocument();
115
+ expect(screen.getByRole('button', { name: /approve/i })).toBeInTheDocument();
116
+ expect(screen.getByRole('button', { name: /add note/i })).toBeInTheDocument();
117
+ expect(screen.queryByText('You can view this topic.')).toBeNull();
118
+ });
119
+ });