@startsimpli/ui 0.4.140 → 0.4.142

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.
Files changed (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/ActivityTimeline.tsx +17 -12
  3. package/src/components/ActorIdentity.tsx +15 -2
  4. package/src/components/activity/ActivityFeedPanel.tsx +96 -29
  5. package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
  6. package/src/components/activity/types.ts +10 -5
  7. package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
  8. package/src/components/collection/InlineReviewActions.tsx +3 -2
  9. package/src/components/collection/ReviewDrawer.tsx +35 -7
  10. package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
  11. package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
  12. package/src/components/collection/data.ts +16 -1
  13. package/src/components/collection/index.ts +2 -1
  14. package/src/components/collection/scoped-client.ts +4 -1
  15. package/src/components/collection/types.ts +19 -1
  16. package/src/components/history/FieldAttribution.tsx +308 -0
  17. package/src/components/history/RecordActivityPanel.tsx +344 -0
  18. package/src/components/history/RecordHistoryPanel.tsx +381 -29
  19. package/src/components/history/RestoreConfirmDialog.tsx +244 -0
  20. package/src/components/history/RevisionFields.tsx +78 -6
  21. package/src/components/history/StaleSaveDialog.tsx +28 -7
  22. package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
  23. package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
  24. package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
  25. package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
  26. package/src/components/history/__tests__/restore.test.ts +209 -0
  27. package/src/components/history/index.ts +72 -1
  28. package/src/components/history/record-history-panel-default-class-names.ts +1 -1
  29. package/src/components/history/restore.ts +295 -0
  30. package/src/components/history/revision-feed.ts +105 -0
  31. package/src/components/history/revision-history.ts +22 -0
  32. package/src/components/history/types.ts +153 -1
@@ -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}&apos;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
+ }