@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 +2 -2
- package/src/components/collection/ReviewDrawer.tsx +4 -11
- package/src/components/collection/__tests__/review-drawer-chips.test.tsx +88 -0
- package/src/components/document-editor/DocumentEditor.tsx +11 -1
- package/src/components/document-editor/__tests__/DocumentEditor.test.tsx +16 -2
- package/src/components/history/RecordActivityPanel.tsx +4 -1
- package/src/components/history/RecordHistoryPanel.tsx +21 -1
- package/src/components/history/RevisionFields.tsx +3 -1
- package/src/components/history/__tests__/record-activity-panel.test.tsx +44 -1
- package/src/components/history/__tests__/record-history-panel.test.tsx +31 -0
- package/src/components/history/index.ts +1 -0
- package/src/components/history/revision-history.ts +30 -1
- package/src/components/history/stale-save-dialog-default-class-names.ts +3 -1
- package/src/components/navigation/__tests__/grouped-nav.test.tsx +9 -0
- package/src/components/navigation/grouped-nav.tsx +3 -1
- package/src/components/track-changes/ChangesRail.tsx +2 -2
- package/src/components/track-changes/TrackedTextEditor.tsx +3 -1
- package/src/components/track-changes/__tests__/rails.test.tsx +17 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
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
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
...(
|
|
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:
|
|
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
|
+
})
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 ')
|