@startsimpli/ui 0.4.141 → 0.4.143
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 +38 -12
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +331 -15
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +69 -3
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- package/src/components/history/types.ts +153 -1
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* RecordActivityPanel — everything one person (or one agent) changed, across
|
|
5
|
+
* records (bd startsim-ucg5u; route bd startsim-ivr3n).
|
|
6
|
+
*
|
|
7
|
+
* NOT A SECOND FEED. It composes the shared `ActivityFeedPanel` (startsim-
|
|
8
|
+
* 768w.19.5) — header, day grouping, infinite scroll, empty/filtered/error
|
|
9
|
+
* states — through the props that panel grew for exactly this: cursor paging,
|
|
10
|
+
* controlled filters, a param allowlist and a row mapper. The rows themselves
|
|
11
|
+
* are the history panel's: the same fold, the same headline, the same
|
|
12
|
+
* `RevisionFields` with its inline diff, and the same `ActorIdentity` kind
|
|
13
|
+
* marker, so a machine reads as a machine here exactly as on the record.
|
|
14
|
+
*
|
|
15
|
+
* FILTERS BELONG TO THE HOST, and the host keeps them in the URL — on a PAGE,
|
|
16
|
+
* never in a Next layout (a layout does not re-render on soft navigation, so a
|
|
17
|
+
* filter kept there goes stale on the first click). This panel only reports a
|
|
18
|
+
* change through `onFiltersChange`.
|
|
19
|
+
*
|
|
20
|
+
* Layout uses CONTAINER queries (bd startsim-jkkn7.12): the filter bar wraps on
|
|
21
|
+
* the width it is given, not the viewport's.
|
|
22
|
+
*/
|
|
23
|
+
import { useCallback, useMemo, useRef, type ReactNode } from 'react';
|
|
24
|
+
import { Pencil, X } from 'lucide-react';
|
|
25
|
+
|
|
26
|
+
import { ActivityFeedPanel } from '../activity/ActivityFeedPanel';
|
|
27
|
+
import type { ActivityTimelineItem } from '../ActivityTimeline';
|
|
28
|
+
import { RevisionFields } from './RevisionFields';
|
|
29
|
+
import { REVISION_ENTRY_ICONS, revisionEntryType, versionNote } from './RecordHistoryPanel';
|
|
30
|
+
import {
|
|
31
|
+
foldFeed,
|
|
32
|
+
localDateInput,
|
|
33
|
+
localDayEnd,
|
|
34
|
+
localDayStart,
|
|
35
|
+
revisionFeedParams,
|
|
36
|
+
startOfThisWeek,
|
|
37
|
+
} from './revision-feed';
|
|
38
|
+
import { COLLAPSE_WINDOW_MS, sittingHeadline, sittingSummary } from './revision-history';
|
|
39
|
+
import type {
|
|
40
|
+
RevisionFeedClient,
|
|
41
|
+
RevisionFeedEntity,
|
|
42
|
+
RevisionFeedFilters,
|
|
43
|
+
RevisionFeedQuery,
|
|
44
|
+
RevisionFeedRow,
|
|
45
|
+
} from './types';
|
|
46
|
+
|
|
47
|
+
export interface RecordActivityOption {
|
|
48
|
+
value: string;
|
|
49
|
+
label: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RecordActivityPanelProps {
|
|
53
|
+
client: RevisionFeedClient;
|
|
54
|
+
/** The host's filters — read from the URL. */
|
|
55
|
+
filters: RevisionFeedFilters;
|
|
56
|
+
/** Write them back to the URL. */
|
|
57
|
+
onFiltersChange: (next: RevisionFeedFilters) => void;
|
|
58
|
+
/** Record types to offer. Absent: derived from the rows received. */
|
|
59
|
+
typeOptions?: RecordActivityOption[];
|
|
60
|
+
/** People to offer, value = actor SUB. Merged with those seen in rows. */
|
|
61
|
+
actorOptions?: RecordActivityOption[];
|
|
62
|
+
/** Where a row's record lives. Omit to render the name as plain text. */
|
|
63
|
+
recordHref?: (entity: RevisionFeedEntity) => string | undefined;
|
|
64
|
+
/** Render a link (e.g. Next's `<Link>`); a plain `<a>` by default. */
|
|
65
|
+
renderLink?: (props: { href: string; className: string; children: ReactNode }) => ReactNode;
|
|
66
|
+
/** A record type's reader-facing name. */
|
|
67
|
+
typeLabel?: (type: string) => string;
|
|
68
|
+
fieldLabel?: (field: string) => string;
|
|
69
|
+
queryKey?: readonly unknown[];
|
|
70
|
+
pageSize?: number;
|
|
71
|
+
windowMs?: number;
|
|
72
|
+
title?: ReactNode;
|
|
73
|
+
description?: ReactNode;
|
|
74
|
+
headerAside?: ReactNode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const SELECT =
|
|
78
|
+
'h-8 min-w-0 rounded-md border border-border bg-background px-2 text-sm text-foreground';
|
|
79
|
+
const LABEL = 'flex min-w-0 flex-col gap-1 text-xs text-muted-foreground';
|
|
80
|
+
const CHIP =
|
|
81
|
+
'inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2 py-0.5 text-xs text-foreground';
|
|
82
|
+
|
|
83
|
+
const KIND_OPTIONS: RecordActivityOption[] = [
|
|
84
|
+
{ value: 'person', label: 'People' },
|
|
85
|
+
{ value: 'machine', label: 'Machines' },
|
|
86
|
+
{ value: 'unknown', label: 'Not recorded' },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
/** Drop blanks so "unfiltered" is `{}` — the URL a host writes stays clean. */
|
|
90
|
+
function prune(f: RevisionFeedFilters): RevisionFeedFilters {
|
|
91
|
+
return revisionFeedParams(f);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function sortOptions(map: Map<string, string>): RecordActivityOption[] {
|
|
95
|
+
return [...map].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function FeedFilterBar({
|
|
99
|
+
value,
|
|
100
|
+
onChange,
|
|
101
|
+
actors,
|
|
102
|
+
types,
|
|
103
|
+
typeLabel,
|
|
104
|
+
}: {
|
|
105
|
+
value: RevisionFeedFilters;
|
|
106
|
+
onChange: (next: RevisionFeedFilters) => void;
|
|
107
|
+
actors: RecordActivityOption[];
|
|
108
|
+
types: RecordActivityOption[];
|
|
109
|
+
typeLabel: (type: string) => string;
|
|
110
|
+
}) {
|
|
111
|
+
const set = (key: keyof RevisionFeedFilters, next: string) => onChange(prune({ ...value, [key]: next }));
|
|
112
|
+
const actorName = (sub: string) => actors.find((a) => a.value === sub)?.label ?? sub;
|
|
113
|
+
const kindName = (k: string) => KIND_OPTIONS.find((o) => o.value === k)?.label ?? k;
|
|
114
|
+
const chips: Array<{ key: keyof RevisionFeedFilters; text: string }> = [];
|
|
115
|
+
if (value.actor) chips.push({ key: 'actor', text: `Person: ${actorName(value.actor)}` });
|
|
116
|
+
if (value.actor_kind) chips.push({ key: 'actor_kind', text: `Made by: ${kindName(value.actor_kind)}` });
|
|
117
|
+
if (value.type) chips.push({ key: 'type', text: `Type: ${typeLabel(value.type)}` });
|
|
118
|
+
if (value.scope) chips.push({ key: 'scope', text: `Scope: ${value.scope}` });
|
|
119
|
+
if (value.since) chips.push({ key: 'since', text: `From ${localDateInput(value.since)}` });
|
|
120
|
+
if (value.until) chips.push({ key: 'until', text: `To ${localDateInput(value.until)}` });
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="@container space-y-2" data-testid="record-activity-filters">
|
|
124
|
+
<div className="grid grid-cols-1 gap-2 @md:grid-cols-2 @3xl:grid-cols-5">
|
|
125
|
+
<label className={LABEL}>
|
|
126
|
+
Person
|
|
127
|
+
<select className={SELECT} value={value.actor ?? ''} onChange={(e) => set('actor', e.target.value)}>
|
|
128
|
+
<option value="">Anyone</option>
|
|
129
|
+
{actors.map((o) => (
|
|
130
|
+
<option key={o.value} value={o.value}>
|
|
131
|
+
{o.label}
|
|
132
|
+
</option>
|
|
133
|
+
))}
|
|
134
|
+
</select>
|
|
135
|
+
</label>
|
|
136
|
+
<label className={LABEL}>
|
|
137
|
+
Made by
|
|
138
|
+
<select
|
|
139
|
+
className={SELECT}
|
|
140
|
+
value={value.actor_kind ?? ''}
|
|
141
|
+
onChange={(e) => set('actor_kind', e.target.value)}
|
|
142
|
+
>
|
|
143
|
+
<option value="">People and machines</option>
|
|
144
|
+
{KIND_OPTIONS.map((o) => (
|
|
145
|
+
<option key={o.value} value={o.value}>
|
|
146
|
+
{o.label}
|
|
147
|
+
</option>
|
|
148
|
+
))}
|
|
149
|
+
</select>
|
|
150
|
+
</label>
|
|
151
|
+
<label className={LABEL}>
|
|
152
|
+
Record type
|
|
153
|
+
<select className={SELECT} value={value.type ?? ''} onChange={(e) => set('type', e.target.value)}>
|
|
154
|
+
<option value="">All types</option>
|
|
155
|
+
{types.map((o) => (
|
|
156
|
+
<option key={o.value} value={o.value}>
|
|
157
|
+
{o.label}
|
|
158
|
+
</option>
|
|
159
|
+
))}
|
|
160
|
+
</select>
|
|
161
|
+
</label>
|
|
162
|
+
<label className={LABEL}>
|
|
163
|
+
From
|
|
164
|
+
<input
|
|
165
|
+
type="date"
|
|
166
|
+
className={SELECT}
|
|
167
|
+
value={localDateInput(value.since)}
|
|
168
|
+
onChange={(e) => set('since', e.target.value ? localDayStart(e.target.value) : '')}
|
|
169
|
+
/>
|
|
170
|
+
</label>
|
|
171
|
+
<label className={LABEL}>
|
|
172
|
+
To
|
|
173
|
+
<input
|
|
174
|
+
type="date"
|
|
175
|
+
className={SELECT}
|
|
176
|
+
value={localDateInput(value.until)}
|
|
177
|
+
onChange={(e) => set('until', e.target.value ? localDayEnd(e.target.value) : '')}
|
|
178
|
+
/>
|
|
179
|
+
</label>
|
|
180
|
+
</div>
|
|
181
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-foreground hover:bg-accent"
|
|
185
|
+
onClick={() => onChange(prune({ ...value, since: startOfThisWeek(), until: '' }))}
|
|
186
|
+
>
|
|
187
|
+
This week
|
|
188
|
+
</button>
|
|
189
|
+
{chips.map((chip) => (
|
|
190
|
+
<span key={chip.key} className={CHIP}>
|
|
191
|
+
{chip.text}
|
|
192
|
+
<button
|
|
193
|
+
type="button"
|
|
194
|
+
aria-label={`Remove ${chip.text}`}
|
|
195
|
+
onClick={() => set(chip.key, '')}
|
|
196
|
+
className="text-muted-foreground hover:text-foreground"
|
|
197
|
+
>
|
|
198
|
+
<X className="h-3 w-3" aria-hidden="true" />
|
|
199
|
+
</button>
|
|
200
|
+
</span>
|
|
201
|
+
))}
|
|
202
|
+
{chips.length > 0 && (
|
|
203
|
+
<button
|
|
204
|
+
type="button"
|
|
205
|
+
className="text-xs text-muted-foreground underline underline-offset-2"
|
|
206
|
+
onClick={() => onChange({})}
|
|
207
|
+
>
|
|
208
|
+
Clear all
|
|
209
|
+
</button>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function RecordActivityPanel({
|
|
217
|
+
client,
|
|
218
|
+
filters,
|
|
219
|
+
onFiltersChange,
|
|
220
|
+
typeOptions,
|
|
221
|
+
actorOptions = [],
|
|
222
|
+
recordHref,
|
|
223
|
+
renderLink,
|
|
224
|
+
typeLabel = (t) => t,
|
|
225
|
+
fieldLabel = (f) => f,
|
|
226
|
+
queryKey = ['record-activity'],
|
|
227
|
+
pageSize = 50,
|
|
228
|
+
windowMs = COLLAPSE_WINDOW_MS,
|
|
229
|
+
title = 'Edits',
|
|
230
|
+
description = 'Who changed which record, newest first — people and machines. Filter to one person, to machine-only edits, to a type or a date range.',
|
|
231
|
+
headerAside,
|
|
232
|
+
}: RecordActivityPanelProps) {
|
|
233
|
+
const applied = useMemo(() => prune(filters), [filters]);
|
|
234
|
+
|
|
235
|
+
// The route's page has no `count` and may omit `results` on an older build.
|
|
236
|
+
const feedClient = useMemo(
|
|
237
|
+
() => ({
|
|
238
|
+
list: async (params: Record<string, unknown>) => {
|
|
239
|
+
const page = await client.list(params as RevisionFeedQuery);
|
|
240
|
+
return { ...page, results: page?.results ?? [] };
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
[client],
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
// Sticky options: a person once seen stays offerable, so narrowing to them
|
|
247
|
+
// does not empty the picker you narrowed with.
|
|
248
|
+
const seenActors = useRef(new Map<string, string>());
|
|
249
|
+
const seenTypes = useRef(new Map<string, string>());
|
|
250
|
+
|
|
251
|
+
const link = useCallback(
|
|
252
|
+
(href: string, children: ReactNode) => {
|
|
253
|
+
const className = 'font-medium text-foreground underline-offset-2 hover:underline';
|
|
254
|
+
return renderLink ? renderLink({ href, className, children }) : (
|
|
255
|
+
<a href={href} className={className}>
|
|
256
|
+
{children}
|
|
257
|
+
</a>
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
[renderLink],
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const toItems = useCallback(
|
|
264
|
+
(rows: RevisionFeedRow[]): ActivityTimelineItem[] =>
|
|
265
|
+
foldFeed(rows, windowMs).map(({ id, entity, sitting }) => {
|
|
266
|
+
const name = (entity?.name ?? '').trim() || (entity?.type ? `Untitled ${typeLabel(entity.type)}` : 'A record');
|
|
267
|
+
const href = entity ? recordHref?.(entity) : undefined;
|
|
268
|
+
const actorText = sitting.actorLabel || sitting.actorSub;
|
|
269
|
+
return {
|
|
270
|
+
id,
|
|
271
|
+
type: revisionEntryType(sitting),
|
|
272
|
+
title: sittingHeadline(sitting, fieldLabel),
|
|
273
|
+
occurredAt: sitting.at,
|
|
274
|
+
actor: { email: sitting.actorLabel || undefined, id: sitting.actorSub || undefined },
|
|
275
|
+
actorKind: sitting.actorKind,
|
|
276
|
+
...(sitting.actorKindSource ? { actorKindSource: sitting.actorKindSource } : {}),
|
|
277
|
+
tone: sitting.isAttempt ? 'warn' : undefined,
|
|
278
|
+
subtitle: (
|
|
279
|
+
<span data-testid="record-activity-target">
|
|
280
|
+
on {href ? link(href, name) : <span className="font-medium text-foreground">{name}</span>}
|
|
281
|
+
{entity?.type ? ` · ${typeLabel(entity.type)}` : ''} · {versionNote(sitting)}
|
|
282
|
+
</span>
|
|
283
|
+
),
|
|
284
|
+
note: sitting.saves > 1 ? sittingSummary(sitting) : undefined,
|
|
285
|
+
detail: (
|
|
286
|
+
<div className="space-y-2">
|
|
287
|
+
<RevisionFields sitting={sitting} fieldLabel={fieldLabel} />
|
|
288
|
+
{sitting.actorSub && applied.actor !== sitting.actorSub && (
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
className="text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
|
|
292
|
+
onClick={() => onFiltersChange(prune({ ...applied, actor: sitting.actorSub }))}
|
|
293
|
+
>
|
|
294
|
+
Only {actorText}'s edits
|
|
295
|
+
</button>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
),
|
|
299
|
+
};
|
|
300
|
+
}),
|
|
301
|
+
[windowMs, typeLabel, recordHref, link, fieldLabel, applied, onFiltersChange],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
return (
|
|
305
|
+
<ActivityFeedPanel<RevisionFeedRow, RevisionFeedFilters>
|
|
306
|
+
client={feedClient}
|
|
307
|
+
paging="cursor"
|
|
308
|
+
filters={applied}
|
|
309
|
+
onFiltersChange={(next) => onFiltersChange(prune(next))}
|
|
310
|
+
toParams={(f) => ({ ...revisionFeedParams(f) })}
|
|
311
|
+
toItems={toItems}
|
|
312
|
+
renderFilters={({ value, onChange, events }) => {
|
|
313
|
+
for (const o of actorOptions) seenActors.current.set(o.value, o.label);
|
|
314
|
+
for (const row of events) {
|
|
315
|
+
const sub = row.actor_sub ?? row.actorSub;
|
|
316
|
+
if (sub) seenActors.current.set(sub, (row.actor_label ?? row.actorLabel) || sub);
|
|
317
|
+
if (row.entity?.type) seenTypes.current.set(row.entity.type, typeLabel(row.entity.type));
|
|
318
|
+
}
|
|
319
|
+
if (value.actor && !seenActors.current.has(value.actor)) seenActors.current.set(value.actor, value.actor);
|
|
320
|
+
return (
|
|
321
|
+
<FeedFilterBar
|
|
322
|
+
value={value}
|
|
323
|
+
onChange={onChange}
|
|
324
|
+
actors={sortOptions(seenActors.current)}
|
|
325
|
+
types={typeOptions ?? sortOptions(seenTypes.current)}
|
|
326
|
+
typeLabel={typeLabel}
|
|
327
|
+
/>
|
|
328
|
+
);
|
|
329
|
+
}}
|
|
330
|
+
queryKey={queryKey}
|
|
331
|
+
pageSize={pageSize}
|
|
332
|
+
title={title}
|
|
333
|
+
description={description}
|
|
334
|
+
headerAside={headerAside}
|
|
335
|
+
emptyTitle="No edits recorded yet"
|
|
336
|
+
emptyDescription="Changes to records will appear here, newest first."
|
|
337
|
+
filteredEmptyTitle="No edits match these filters"
|
|
338
|
+
filteredEmptyDescription="Widen the date range or clear a filter to see more."
|
|
339
|
+
errorTitle="Could not load edits"
|
|
340
|
+
activityIcons={REVISION_ENTRY_ICONS}
|
|
341
|
+
defaultIcon={Pencil}
|
|
342
|
+
/>
|
|
343
|
+
);
|
|
344
|
+
}
|