@startsimpli/ui 0.4.160 → 0.4.161

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.160",
3
+ "version": "0.4.161",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -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",
@@ -299,7 +299,9 @@ function ReviewDrawerInner({
299
299
  {status.replace(/_/g, ' ')}
300
300
  </span>
301
301
  ) : null
302
- ) : cfg.statusChoices.length > 0 ? (
302
+ ) : mode !== 'edit' && cfg.statusChoices.length > 0 ? (
303
+ // Not while editing fields: the form carries the same status field,
304
+ // and two controls for one value can be set apart (bd startsim-uc9fz).
303
305
  <Select value={status || undefined} onValueChange={overrideStatus}>
304
306
  <SelectTrigger className="h-7 w-auto gap-1 border-border text-xs capitalize">
305
307
  <SelectValue placeholder="State…" />
@@ -379,7 +381,7 @@ function ReviewDrawerInner({
379
381
  <h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
380
382
 
381
383
  <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs">
382
- {rank ? (
384
+ {rank && Number(rank) !== 0 ? (
383
385
  <span className="rounded-full bg-primary/10 px-2 py-0.5 font-semibold text-primary">
384
386
  rank {rank}
385
387
  </span>
@@ -514,19 +516,10 @@ function DecisionButton({
514
516
  <Icon className="h-4 w-4" />
515
517
  {busy ? 'Saving…' : decision.label}
516
518
  </span>
517
- <span className={cn('text-[10px] font-normal', decision.tone === 'good' ? 'text-white/80' : 'opacity-70')}>
518
- {decisionHint(decision)}
519
- </span>
520
519
  </button>
521
520
  );
522
521
  }
523
522
 
524
- /** The tiny "→ ready · good" outcome hint under a decision. */
525
- function decisionHint(d: ReviewDecision): string {
526
- const parts = [d.status ? `→ ${d.status}` : null, d.verdict].filter(Boolean);
527
- return parts.join(' · ');
528
- }
529
-
530
523
  /** Source chips: host + optional tier badge, linked when a url is present. */
531
524
  function ReviewSources({
532
525
  data,
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The topic drawer's header chips and decision buttons read as plain words
3
+ * (bd startsim-piff1): a rank of 0 is no rank, and the buttons no longer carry
4
+ * the raw "→ ready · good" status/verdict hint.
5
+ */
6
+ import * as React from 'react';
7
+ import { render, screen } from '@testing-library/react';
8
+ import userEvent from '@testing-library/user-event';
9
+
10
+ import { ReviewDrawer } from '../ReviewDrawer';
11
+ import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
12
+
13
+ const TOPIC_TYPE: EntityTypeDef = {
14
+ id: 1,
15
+ key: 'topic',
16
+ label: 'Topic',
17
+ attributes: [
18
+ { id: 'status', name: 'status', dataType: 'enum', required: false, config: { choices: ['suggested', 'ready', 'written', 'rejected'] } },
19
+ { id: 'ai_rank', name: 'ai_rank', dataType: 'number', required: false, config: {} },
20
+ { id: 'team_verdict', name: 'team_verdict', dataType: 'text', required: false, config: {} },
21
+ ],
22
+ };
23
+
24
+ function topic(rank: unknown): EntityRecord {
25
+ return {
26
+ id: 1,
27
+ entityType: 'topic',
28
+ externalId: null,
29
+ name: 'Topic 1',
30
+ data: { status: 'suggested', ai_rank: rank },
31
+ createdAt: '2026-09-01T00:00:00Z',
32
+ };
33
+ }
34
+
35
+ function page<T>(results: T[]): Paginated<T> {
36
+ return { count: results.length, next: null, previous: null, results };
37
+ }
38
+
39
+ function client(): CollectionClient {
40
+ return {
41
+ listTypes: jest.fn(async () => page([TOPIC_TYPE])),
42
+ listAllEntities: jest.fn(async () => []),
43
+ updateEntity: jest.fn(),
44
+ } as unknown as CollectionClient;
45
+ }
46
+
47
+ function renderDrawer(rank: unknown) {
48
+ const r = topic(rank);
49
+ render(
50
+ <ReviewDrawer
51
+ client={client()}
52
+ type={TOPIC_TYPE}
53
+ records={[r]}
54
+ record={r}
55
+ onClose={jest.fn()}
56
+ onNavigate={jest.fn()}
57
+ renderEditFields={() => <p>the form</p>}
58
+ />,
59
+ );
60
+ }
61
+
62
+ describe('ReviewDrawer header chips', () => {
63
+ it('hides a rank of 0', () => {
64
+ renderDrawer(0);
65
+ expect(screen.queryByText(/rank 0/)).toBeNull();
66
+ });
67
+
68
+ it('still shows a real rank', () => {
69
+ renderDrawer(7);
70
+ expect(screen.getByText('rank 7')).toBeInTheDocument();
71
+ });
72
+
73
+ it('decision buttons carry no raw status/verdict hint', () => {
74
+ renderDrawer(7);
75
+ expect(screen.queryByText(/→/)).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe('ReviewDrawer edit mode (bd startsim-uc9fz)', () => {
80
+ it('drops the header State select while the field form is open', async () => {
81
+ const user = userEvent.setup();
82
+ renderDrawer(7);
83
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
84
+ await user.click(screen.getByRole('button', { name: /Edit fields/ }));
85
+ expect(screen.getByText('the form')).toBeInTheDocument();
86
+ expect(screen.queryByRole('combobox')).toBeNull();
87
+ });
88
+ });
@@ -352,6 +352,16 @@ function TextSection({
352
352
  );
353
353
  }
354
354
 
355
+ /** "primaryKeyword" / "meta_description" → "Primary keyword" / "Meta description". */
356
+ function structuredFieldLabel(key: string): string {
357
+ const spaced = key
358
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
359
+ .replace(/[_-]+/g, ' ')
360
+ .trim()
361
+ .toLowerCase();
362
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
363
+ }
364
+
355
365
  function StructuredSection({
356
366
  section,
357
367
  readOnly,
@@ -377,7 +387,7 @@ function StructuredSection({
377
387
  return (
378
388
  <div key={field} className="grid gap-1 sm:grid-cols-[10rem_minmax(0,1fr)] sm:items-start sm:gap-3">
379
389
  <label htmlFor={`${section.key}-${field}`} className="pt-1.5 text-xs font-medium text-muted-foreground">
380
- {field.replace(/[_-]+/g, ' ')}
390
+ {structuredFieldLabel(field)}
381
391
  </label>
382
392
  <div>
383
393
  {readOnly ? (
@@ -73,12 +73,12 @@ describe('DocumentEditor', () => {
73
73
 
74
74
  it('structured section renders a field input and an SEO char meter on meta_description', () => {
75
75
  render(<Harness />);
76
- const metaInput = screen.getByLabelText('meta description') as HTMLInputElement;
76
+ const metaInput = screen.getByLabelText('Meta description') as HTMLInputElement;
77
77
  expect(metaInput.value).toBe('short');
78
78
  // the meta meter surfaces the aim band
79
79
  expect(screen.getByText(/aim 150/)).toBeInTheDocument();
80
80
  fireEvent.change(metaInput, { target: { value: 'new description' } });
81
- expect((screen.getByLabelText('meta description') as HTMLInputElement).value).toBe('new description');
81
+ expect((screen.getByLabelText('Meta description') as HTMLInputElement).value).toBe('new description');
82
82
  });
83
83
 
84
84
  it('list section adds, edits and removes items', () => {
@@ -123,3 +123,17 @@ describe('DocumentEditor', () => {
123
123
  expect(onSave).not.toHaveBeenCalled();
124
124
  });
125
125
  });
126
+
127
+ describe('DocumentEditor structured labels (bd startsim-5w5p6)', () => {
128
+ it('labels camelCase and snake_case keys in plain words', () => {
129
+ render(
130
+ <DocumentEditor
131
+ sections={[{ key: 'seo', label: 'SEO', kind: 'structured', value: { primaryKeyword: 'kw', meta_description: 'm', tags: 't' } }]}
132
+ onChange={() => {}}
133
+ />,
134
+ );
135
+ expect(screen.getByText('Primary keyword')).toBeInTheDocument();
136
+ expect(screen.getByText('Meta description')).toBeInTheDocument();
137
+ expect(screen.getByText('Tags')).toBeInTheDocument();
138
+ });
139
+ });
@@ -169,6 +169,9 @@ export interface RecordHistoryPanelProps {
169
169
  headerAside?: ReactNode;
170
170
  /** Map a wire field name to a reader-facing label. */
171
171
  fieldLabel?: (field: string) => string;
172
+ /** Leave a field out of every entry — its row, and the entry's field count —
173
+ * e.g. a host's system bookkeeping keys (`_run_id`). Nothing hidden by default. */
174
+ hideField?: (field: string) => boolean;
172
175
  /** Group entries under day headings. On by default — a record's history is
173
176
  * read by date far more often than as a flat list. */
174
177
  groupByDay?: boolean;
@@ -226,6 +229,19 @@ export interface RecordHistoryPanelProps {
226
229
  classNames?: RecordHistoryPanelClassNames;
227
230
  }
228
231
 
232
+ /** A sitting with the host's hidden fields taken out, so the headline's count
233
+ * and the rows under it agree. */
234
+ function withoutFields(sitting: RevisionSitting, hide: (field: string) => boolean): RevisionSitting {
235
+ const keep = (f: string) => !hide(f);
236
+ return {
237
+ ...sitting,
238
+ changed: Object.fromEntries(Object.entries(sitting.changed).filter(([f]) => keep(f))),
239
+ preserved: sitting.preserved.filter(keep),
240
+ regenerate: sitting.regenerate.filter(keep),
241
+ redacted: sitting.redacted.filter(keep),
242
+ };
243
+ }
244
+
229
245
  export function RecordHistoryPanel({
230
246
  client,
231
247
  queryKey = ['record-history'],
@@ -236,6 +252,7 @@ export function RecordHistoryPanel({
236
252
  description = 'Who changed which field, from what to what, newest first.',
237
253
  headerAside,
238
254
  fieldLabel = (field) => field,
255
+ hideField,
239
256
  groupByDay = true,
240
257
  emptyTitle = 'No edits recorded for this record',
241
258
  // NOT "this record has never changed" — see sentence 3 in the header.
@@ -427,7 +444,10 @@ export function RecordHistoryPanel({
427
444
  }, []);
428
445
 
429
446
  // THE FOLD RUNS OVER THE ACCUMULATION, never per page. See the header.
430
- const sittings = useMemo(() => foldRevisions(revisions, windowMs), [revisions, windowMs]);
447
+ const sittings = useMemo(() => {
448
+ const folded = foldRevisions(revisions, windowMs);
449
+ return hideField ? folded.map((s) => withoutFields(s, hideField)) : folded;
450
+ }, [revisions, windowMs, hideField]);
431
451
 
432
452
  const items = useMemo<ActivityTimelineItem[]>(
433
453
  () =>
@@ -458,3 +458,34 @@ describe('RecordHistoryPanel — the unrecorded-kind sentence is scoped (bd star
458
458
  expect(note.textContent).toMatch(/for the rest/i)
459
459
  })
460
460
  })
461
+
462
+ describe('RecordHistoryPanel — hideField (bd startsim-mpjbi, startsim-l1ya5)', () => {
463
+ it('leaves hidden fields out of the rows AND the entry count', async () => {
464
+ mount(
465
+ <RecordHistoryPanel
466
+ hideField={(f) => f.startsWith('_')}
467
+ fieldLabel={(f) => (f === 'team_verdict' ? 'Team verdict' : f)}
468
+ client={clientFor(
469
+ page([
470
+ rev({
471
+ version: 1,
472
+ action: 'created',
473
+ metadata: {
474
+ changed: {
475
+ _run_id: { before: null, after: 'r1' },
476
+ _origin: { before: null, after: 'n8n' },
477
+ blog: { before: null, after: 'x' },
478
+ team_verdict: { before: null, after: 'good' },
479
+ },
480
+ },
481
+ }),
482
+ ]),
483
+ )}
484
+ />,
485
+ )
486
+ await screen.findByTestId('record-history-panel')
487
+ expect(await screen.findByText(/Created the record with 2 fields/)).toBeInTheDocument()
488
+ expect(screen.queryByText('_run_id')).toBeNull()
489
+ expect(screen.queryByText('_origin')).toBeNull()
490
+ })
491
+ })
@@ -41,5 +41,7 @@ export const STALE_SAVE_DIALOG_DEFAULTS: Required<StaleSaveDialogClassNames> = {
41
41
  // Underplayed ON PURPOSE. It is the only control here that destroys the text
42
42
  // this dialog exists to protect, so it must never be the thing a hurried
43
43
  // reviewer clicks by muscle memory.
44
- destructiveAction: 'text-xs text-muted-foreground underline underline-offset-2',
44
+ // Red so it does not read as a help link, and pushed to the far left, apart
45
+ // from the two safe buttons.
46
+ destructiveAction: 'me-auto text-xs text-destructive underline underline-offset-2 disabled:opacity-50',
45
47
  };
@@ -67,3 +67,12 @@ describe('GroupedNav', () => {
67
67
  expect(link).toHaveAttribute('data-router-link', '1')
68
68
  })
69
69
  })
70
+
71
+ describe('GroupedNav: a group holding the current page (bd startsim-m7fdm.39)', () => {
72
+ it('draws its header as a plain heading, not a collapse button that does nothing', () => {
73
+ render(<GroupedNav items={items} activeHref="/t/general" />)
74
+ expect(screen.queryByRole('button', { name: /Content/ })).toBeNull()
75
+ expect(screen.getByText('Content')).toBeInTheDocument()
76
+ expect(screen.getByText('General')).toBeInTheDocument()
77
+ })
78
+ })
@@ -170,7 +170,9 @@ function NavGroup({
170
170
 
171
171
  return (
172
172
  <div className="pt-3 first:pt-0">
173
- {collapsible ? (
173
+ {/* A group holding the current page cannot close, so its header is not
174
+ drawn as a control that does nothing (bd startsim-m7fdm.39). */}
175
+ {collapsible && !hasActiveChild ? (
174
176
  <button
175
177
  type="button"
176
178
  onClick={() => setOpen((o) => !o)}
@@ -184,12 +184,12 @@ export function ChangesRail(props: ChangesRailProps) {
184
184
  </h3>
185
185
  <div className="flex flex-wrap gap-1">
186
186
  {onPrevChange && (
187
- <button type="button" className={btn} onClick={onPrevChange} title="Shift+F8 in the text">
187
+ <button type="button" className={btn} onClick={onPrevChange} disabled={open.length === 0} title="Shift+F8 in the text">
188
188
  Previous
189
189
  </button>
190
190
  )}
191
191
  {onNextChange && (
192
- <button type="button" className={btn} onClick={onNextChange} title="F8 in the text">
192
+ <button type="button" className={btn} onClick={onNextChange} disabled={open.length === 0} title="F8 in the text">
193
193
  Next change
194
194
  </button>
195
195
  )}
@@ -531,7 +531,9 @@ export const TrackedTextEditor = forwardRef<TrackedTextEditorHandle, TrackedText
531
531
  : undefined;
532
532
 
533
533
  let proposalLine: string | null = null;
534
- if (mode === 'suggesting' && canSuggest) {
534
+ // Nothing typed and nothing in flight: no line. "0 changes" read as a count
535
+ // of saved suggestions sitting right above the rail's own count.
536
+ if (mode === 'suggesting' && canSuggest && (localCount > 0 || proposal.state !== 'idle')) {
535
537
  const n = `${localCount} ${localCount === 1 ? 'change' : 'changes'}`;
536
538
  proposalLine =
537
539
  proposal.state === 'error'
@@ -624,3 +624,20 @@ describe('TrackedField', () => {
624
624
  )
625
625
  })
626
626
  })
627
+
628
+ describe('ChangesRail: nothing to step to (bd startsim-5378r)', () => {
629
+ it('disables Previous and Next change when there are no open items', () => {
630
+ render(
631
+ <ChangesRail fieldLabel="Blog" currentActorSub="ada" suggestions={[]} threads={[]} onPrevChange={jest.fn()} onNextChange={jest.fn()} />,
632
+ )
633
+ expect(screen.getByRole('button', { name: 'Previous' })).toBeDisabled()
634
+ expect(screen.getByRole('button', { name: 'Next change' })).toBeDisabled()
635
+ })
636
+
637
+ it('enables them when there is something to step to', () => {
638
+ render(
639
+ <ChangesRail fieldLabel="Blog" currentActorSub="ada" suggestions={[sugg('s1', 'Hello', 'Hi')]} threads={[]} onPrevChange={jest.fn()} onNextChange={jest.fn()} />,
640
+ )
641
+ expect(screen.getByRole('button', { name: 'Next change' })).toBeEnabled()
642
+ })
643
+ })
@@ -82,6 +82,13 @@ describe('suggesting mode', () => {
82
82
  return { ...utils, ref, onChange, onPropose }
83
83
  }
84
84
 
85
+ it('shows no "0 changes" line before anything is typed (bd startsim-9is5h)', () => {
86
+ const { ref } = mountSuggesting()
87
+ expect(screen.queryByTestId('proposal-state')).toBeNull()
88
+ typeAt(ref.current!, 6, 'big ')
89
+ expect(screen.getByTestId('proposal-state')).toHaveTextContent(/not sent yet/)
90
+ })
91
+
85
92
  it('never reports an edit to the host, and posts the whole proposed text after a 5 s pause', async () => {
86
93
  const { ref, onChange, onPropose } = mountSuggesting()
87
94
  typeAt(ref.current!, 6, 'big ')