@startsimpli/ui 0.4.160 → 0.4.162

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.162",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -55,8 +55,8 @@
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
58
  "@startsimpli/auth": "^0.4.43",
59
+ "@startsimpli/api": "^0.5.68",
60
60
  "@startsimpli/i18n": "^0.1.3"
61
61
  },
62
62
  "peerDependenciesMeta": {
@@ -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
+ });
@@ -289,9 +289,12 @@ export function RecordActivityPanel({
289
289
  <button
290
290
  type="button"
291
291
  className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
292
+ title={`Only ${actorText}'s edits`}
292
293
  onClick={() => onFiltersChange(prune({ ...applied, actor: sitting.actorSub }))}
293
294
  >
294
- Only {actorText}&apos;s edits
295
+ {/* The actor's name is printed on the line below; saying it
296
+ again here doubled every card (bd startsim-spywp). */}
297
+ Only their edits
295
298
  </button>
296
299
  )}
297
300
  </div>
@@ -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
  () =>
@@ -33,6 +33,7 @@ import { DiffViewer } from '../diff';
33
33
  import {
34
34
  canDiffFieldChange,
35
35
  classifyFieldChange,
36
+ sittingNameRow,
36
37
  type FoldedFieldChange,
37
38
  type RevisionSitting,
38
39
  } from './revision-history';
@@ -180,13 +181,14 @@ export function RevisionFields({
180
181
  onNarrowToField,
181
182
  }: RevisionFieldsProps) {
182
183
  const [open, setOpen] = useState<string | null>(null);
184
+ const nameRow = sittingNameRow(sitting);
183
185
  const entries: Array<[string, FoldedFieldChange]> = [
184
186
  ...Object.keys(sitting.changed)
185
187
  .sort()
186
188
  .map((field) => [field, sitting.changed[field]!] as [string, FoldedFieldChange]),
187
189
  // The name COLUMN, kept namespaced away from the data keys because a tenant
188
190
  // may declare a data key literally called "name".
189
- ...(sitting.name ? ([['name (title)', sitting.name]] as Array<[string, FoldedFieldChange]>) : []),
191
+ ...(nameRow ? ([['name (title)', nameRow]] as Array<[string, FoldedFieldChange]>) : []),
190
192
  ];
191
193
 
192
194
  return (
@@ -167,7 +167,7 @@ describe('RecordActivityPanel', () => {
167
167
  const onFiltersChange = jest.fn()
168
168
  const list = jest.fn().mockResolvedValue({ next: null, results: [row('1', draftA)] })
169
169
  mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={onFiltersChange} />)
170
- fireEvent.click(await screen.findByRole('button', { name: /only jurga@ogmc\.example/i }))
170
+ fireEvent.click(await screen.findByRole('button', { name: 'Only their edits' }))
171
171
  expect(onFiltersChange).toHaveBeenCalledWith({ actor: 'u-jurga' })
172
172
  })
173
173
 
@@ -177,3 +177,46 @@ describe('RecordActivityPanel', () => {
177
177
  expect(await screen.findByText(/no edits match/i)).toBeInTheDocument()
178
178
  })
179
179
  })
180
+
181
+ describe('an Activity card says each thing once (bd startsim-8udmw, startsim-spywp)', () => {
182
+ const scopeRec = { id: 's-1', type: 'scope', name: 'OGMC' }
183
+
184
+ it('a create counts the rows it lists, and the name row reads "set"', async () => {
185
+ const created = row('1', draftA, {
186
+ version: 1,
187
+ action: 'created',
188
+ metadata: {
189
+ changed: { blog: { after: 'x' }, linkedin: { after: 'y' } },
190
+ name: { before: '', after: 'Gulf logistics brief' },
191
+ },
192
+ })
193
+ const list = jest.fn().mockResolvedValue({ next: null, results: [created] })
194
+ mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={jest.fn()} />)
195
+ expect(await screen.findByText('Created the record with 3 fields')).toBeInTheDocument()
196
+ const nameRow = screen.getByRole('button', { name: /name \(title\)/ })
197
+ expect(nameRow).toHaveTextContent('set')
198
+ expect(nameRow).not.toHaveTextContent('changed')
199
+ })
200
+
201
+ it('a type whose own `name` key mirrors the record name shows one name row', async () => {
202
+ const created = row('1', scopeRec, {
203
+ version: 1,
204
+ action: 'created',
205
+ metadata: {
206
+ changed: { name: { after: 'OGMC' }, path: { after: '/ogmc' } },
207
+ name: { before: null, after: 'OGMC' },
208
+ },
209
+ })
210
+ const list = jest.fn().mockResolvedValue({ next: null, results: [created] })
211
+ mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={jest.fn()} />)
212
+ expect(await screen.findByText('Created the record with 2 fields')).toBeInTheDocument()
213
+ expect(screen.queryByRole('button', { name: /name \(title\)/ })).toBeNull()
214
+ })
215
+
216
+ it('the narrow-to-person link does not repeat the address', async () => {
217
+ const list = jest.fn().mockResolvedValue({ next: null, results: [row('1', draftA)] })
218
+ mount(<RecordActivityPanel client={{ list }} filters={{}} onFiltersChange={jest.fn()} />)
219
+ expect(await screen.findByRole('button', { name: 'Only their edits' })).toBeInTheDocument()
220
+ expect(screen.queryByText(/Only jurga@ogmc\.example's edits/)).toBeNull()
221
+ })
222
+ })
@@ -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
+ })
@@ -50,6 +50,7 @@ export {
50
50
  normalizeFieldChange,
51
51
  normalizeRevision,
52
52
  sittingFields,
53
+ sittingNameRow,
53
54
  sittingHeadline,
54
55
  sittingSummary,
55
56
  } from './revision-history';
@@ -457,6 +457,33 @@ export function sittingFields(sitting: RevisionSitting): string[] {
457
457
  return Object.keys(sitting.changed).sort();
458
458
  }
459
459
 
460
+ /**
461
+ * The record-name row a sitting shows, if any (bd startsim-8udmw).
462
+ *
463
+ * Two corrections over printing `sitting.name` as is:
464
+ *
465
+ * - A type may declare its own data key `name` that mirrors the record name
466
+ * (scope, client). When that key changed to the same value in the same
467
+ * sitting, the name row says it a second time, under a different verb — so
468
+ * it is dropped and the data row stands for both.
469
+ * - On a CREATE there was no name before it. The wire may still carry a
470
+ * `before` (null or blank), which classified the row "changed" on a record
471
+ * that had only just come into being; on a create it reads "set".
472
+ */
473
+ export function sittingNameRow(sitting: RevisionSitting): FoldedFieldChange | undefined {
474
+ const name = sitting.name;
475
+ if (!name) return undefined;
476
+ const dataName = sitting.changed.name;
477
+ if (dataName && dataName.hasAfter && name.hasAfter && String(dataName.after) === String(name.after)) {
478
+ return undefined;
479
+ }
480
+ if (sitting.action === 'created' && name.hasBefore) {
481
+ const { before: _before, ...rest } = name;
482
+ return { ...rest, hasBefore: false };
483
+ }
484
+ return name;
485
+ }
486
+
460
487
  /** "a", "a and b", "a, b and c" — an Oxford-free list, because these are field
461
488
  * names in a sentence and not prose. */
462
489
  export function joinNames(names: string[]): string {
@@ -495,7 +522,9 @@ export function sittingHeadline(
495
522
  : `Restored ${fields.length} fields from ${from}`;
496
523
  }
497
524
  if (sitting.action === 'created') {
498
- return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
525
+ // Counts the rows the reader is shown under it, name row included.
526
+ const count = fields.length + (sittingNameRow(sitting) ? 1 : 0);
527
+ return count ? `Created the record with ${count} ${count === 1 ? 'field' : 'fields'}` : 'Created the record';
499
528
  }
500
529
  if (!fields.length) return renamed ? 'Renamed the record' : 'Saved the record';
501
530
  const named =
@@ -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 ')