@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.
- package/package.json +1 -1
- package/src/components/ActivityTimeline.tsx +17 -12
- package/src/components/ActorIdentity.tsx +15 -2
- 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 +381 -29
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +78 -6
- 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-panel.test.tsx +157 -9
- 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/record-history-panel-default-class-names.ts +1 -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
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RED for bd startsim-jkkn7.20 — a drawer decision asserts the version of the
|
|
3
|
+
* blob it MERGED OVER, never a newer one.
|
|
4
|
+
*
|
|
5
|
+
* ReviewDrawer seeds its local `data` once per record id and every decision,
|
|
6
|
+
* the note and the status override merge onto it. It wrote through the
|
|
7
|
+
* two-argument `updateEntity(id, input)`, so the app could only assert the
|
|
8
|
+
* newest version its registry had seen — and every list refetch moves that. The
|
|
9
|
+
* reproduction (marketing-agents-web drawer-precondition.test.tsx): open at v3,
|
|
10
|
+
* another writer sets the angle (v4), the list refetches, Save note → If-Match
|
|
11
|
+
* "4" with the v3 angle, 200, the other writer's angle erased.
|
|
12
|
+
*
|
|
13
|
+
* The fix pinned here:
|
|
14
|
+
* 1. the drawer keeps the version WITH the data it holds, from `record.version`;
|
|
15
|
+
* 2. `updateEntity` takes an optional third argument `{ expectedVersion }`;
|
|
16
|
+
* 3. a successful write moves the drawer onto the SAVED record's version;
|
|
17
|
+
* 4. the Edit-fields `saved(record)` callback reseeds data and version.
|
|
18
|
+
* Same class, lower exposure: InlineReviewActions merges onto a list row and
|
|
19
|
+
* now asserts that row's version.
|
|
20
|
+
*/
|
|
21
|
+
import * as React from 'react';
|
|
22
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
23
|
+
import userEvent from '@testing-library/user-event';
|
|
24
|
+
|
|
25
|
+
import { ReviewDrawer } from '../ReviewDrawer';
|
|
26
|
+
import { InlineReviewActions } from '../InlineReviewActions';
|
|
27
|
+
import type { CollectionClient, EntityRecord, EntityTypeDef, Paginated } from '../types';
|
|
28
|
+
|
|
29
|
+
const TOPIC_TYPE: EntityTypeDef = {
|
|
30
|
+
id: 1,
|
|
31
|
+
key: 'topic',
|
|
32
|
+
label: 'Topic',
|
|
33
|
+
attributes: [
|
|
34
|
+
{
|
|
35
|
+
id: 'status',
|
|
36
|
+
name: 'status',
|
|
37
|
+
dataType: 'enum',
|
|
38
|
+
required: false,
|
|
39
|
+
config: { choices: ['suggested', 'ready', 'written', 'rejected'] },
|
|
40
|
+
},
|
|
41
|
+
{ id: 'title', name: 'title', dataType: 'text', required: false, config: {} },
|
|
42
|
+
{ id: 'angle', name: 'angle', dataType: 'text', required: false, config: {} },
|
|
43
|
+
{ id: 'team_verdict', name: 'team_verdict', dataType: 'text', required: false, config: {} },
|
|
44
|
+
{ id: 'team_notes', name: 'team_notes', dataType: 'text', required: false, config: {} },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function topic(version: number | undefined, data: Record<string, unknown> = {}): EntityRecord {
|
|
49
|
+
return {
|
|
50
|
+
id: 7,
|
|
51
|
+
entityType: 'topic',
|
|
52
|
+
externalId: null,
|
|
53
|
+
name: 'Qatar',
|
|
54
|
+
data: { title: 'Qatar', status: 'suggested', angle: 'v3 angle', ...data },
|
|
55
|
+
createdAt: '2026-09-01T00:00:00Z',
|
|
56
|
+
...(version !== undefined ? { version } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const page = <T,>(results: T[]): Paginated<T> => ({ count: results.length, next: null, previous: null, results });
|
|
61
|
+
|
|
62
|
+
function stubClient(nextVersion = 4) {
|
|
63
|
+
let v = nextVersion;
|
|
64
|
+
const updateEntity = jest.fn(
|
|
65
|
+
async (id: number | string, input: { data?: Record<string, unknown> }, _opts?: { expectedVersion?: number }) => ({
|
|
66
|
+
...topic(v++, {}),
|
|
67
|
+
id: Number(id),
|
|
68
|
+
data: input.data ?? {},
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
return {
|
|
72
|
+
listTypes: jest.fn(async () => page([TOPIC_TYPE])),
|
|
73
|
+
listAllEntities: jest.fn(async () => []),
|
|
74
|
+
updateEntity,
|
|
75
|
+
} as CollectionClient & { updateEntity: typeof updateEntity };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function drawer(client: CollectionClient, record: EntityRecord, extra: Partial<React.ComponentProps<typeof ReviewDrawer>> = {}) {
|
|
79
|
+
return (
|
|
80
|
+
<ReviewDrawer
|
|
81
|
+
client={client}
|
|
82
|
+
type={TOPIC_TYPE}
|
|
83
|
+
records={[record]}
|
|
84
|
+
record={record}
|
|
85
|
+
onClose={() => {}}
|
|
86
|
+
onNavigate={() => {}}
|
|
87
|
+
{...extra}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('ReviewDrawer — the precondition is the version of the blob it holds', () => {
|
|
93
|
+
it('a decision asserts the version the drawer opened at', async () => {
|
|
94
|
+
const client = stubClient();
|
|
95
|
+
render(drawer(client, topic(3)));
|
|
96
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
97
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
98
|
+
expect(client.updateEntity.mock.calls[0]![2]).toEqual({ expectedVersion: 3 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a LIST REFETCH handing the drawer a newer record does NOT move the asserted version', async () => {
|
|
102
|
+
const client = stubClient();
|
|
103
|
+
const view = render(drawer(client, topic(3)));
|
|
104
|
+
// Another writer set the angle (v4); the parent's list refetched and re-renders
|
|
105
|
+
// the open drawer with the new row. The drawer still holds the v3 blob.
|
|
106
|
+
view.rerender(drawer(client, topic(4, { angle: 'colleague angle' })));
|
|
107
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
108
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
109
|
+
const [, input, opts] = client.updateEntity.mock.calls[0]!;
|
|
110
|
+
// The blob it sends is the v3 one, so the version it asserts must be 3 —
|
|
111
|
+
// which the server refuses with a 412 instead of erasing the angle.
|
|
112
|
+
expect(input.data?.angle).toBe('v3 angle');
|
|
113
|
+
expect(opts).toEqual({ expectedVersion: 3 });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('a successful write moves the drawer onto the SAVED version', async () => {
|
|
117
|
+
const client = stubClient(4);
|
|
118
|
+
render(drawer(client, topic(3)));
|
|
119
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
120
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
121
|
+
await userEvent.click(screen.getByRole('button', { name: /Approve/ }));
|
|
122
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(2));
|
|
123
|
+
expect(client.updateEntity.mock.calls[1]![2]).toEqual({ expectedVersion: 4 });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('a FAILED write keeps the version it had', async () => {
|
|
127
|
+
const client = stubClient();
|
|
128
|
+
client.updateEntity.mockRejectedValueOnce(Object.assign(new Error('stale'), { status: 412 }));
|
|
129
|
+
render(drawer(client, topic(3)));
|
|
130
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
131
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
132
|
+
await userEvent.click(screen.getByRole('button', { name: /Approve/ }));
|
|
133
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(2));
|
|
134
|
+
expect(client.updateEntity.mock.calls[1]![2]).toEqual({ expectedVersion: 3 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('a record with no version sends no precondition (an older tenant build)', async () => {
|
|
138
|
+
const client = stubClient();
|
|
139
|
+
render(drawer(client, topic(undefined)));
|
|
140
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
141
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
142
|
+
expect(client.updateEntity.mock.calls[0]![2]).toBeUndefined();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('Edit fields → saved(record) reseeds the data AND the version from the saved record', async () => {
|
|
146
|
+
const client = stubClient(9);
|
|
147
|
+
let save!: (record?: EntityRecord) => void;
|
|
148
|
+
render(
|
|
149
|
+
drawer(client, topic(3), {
|
|
150
|
+
renderEditFields: ({ saved }) => {
|
|
151
|
+
save = saved;
|
|
152
|
+
return <p>editor</p>;
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
await userEvent.click(screen.getByRole('button', { name: /Edit fields/ }));
|
|
157
|
+
React.act(() => save(topic(6, { title: 'Edited title', angle: 'edited angle' })));
|
|
158
|
+
expect(await screen.findByRole('heading', { name: 'Edited title' })).toBeInTheDocument();
|
|
159
|
+
await userEvent.click(screen.getByRole('button', { name: /Reject/ }));
|
|
160
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
161
|
+
const [, input, opts] = client.updateEntity.mock.calls[0]!;
|
|
162
|
+
expect(input.data?.angle).toBe('edited angle');
|
|
163
|
+
expect(opts).toEqual({ expectedVersion: 6 });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('InlineReviewActions — asserts the row it merged over', () => {
|
|
168
|
+
it('passes the row version', async () => {
|
|
169
|
+
const client = stubClient();
|
|
170
|
+
render(<InlineReviewActions client={client} type={TOPIC_TYPE} record={topic(5)} />);
|
|
171
|
+
await userEvent.click(screen.getAllByRole('button', { name: /Reject/ })[0]!);
|
|
172
|
+
await waitFor(() => expect(client.updateEntity).toHaveBeenCalledTimes(1));
|
|
173
|
+
expect(client.updateEntity.mock.calls[0]![2]).toEqual({ expectedVersion: 5 });
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
*
|
|
43
43
|
* Pure + framework-free.
|
|
44
44
|
*/
|
|
45
|
-
import type { EntityRecord } from './types';
|
|
45
|
+
import type { EntityRecord, EntityWriteOptions } from './types';
|
|
46
46
|
|
|
47
47
|
/** The spelling the shared client produces from a wire key. */
|
|
48
48
|
export function toCamelKey(name: string): string {
|
|
@@ -143,3 +143,18 @@ export function declaredBlob(
|
|
|
143
143
|
}
|
|
144
144
|
return out;
|
|
145
145
|
}
|
|
146
|
+
|
|
147
|
+
/** An integer version, and only that. `0` IS a version (every record that
|
|
148
|
+
* predates the trail reads 0), so `if (version)` would unguard the oldest rows. */
|
|
149
|
+
export function isVersionNumber(value: unknown): value is number {
|
|
150
|
+
return typeof value === 'number' && Number.isInteger(value);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The write options for a blob merged over `record` (bd startsim-jkkn7.20): its
|
|
155
|
+
* OWN version, or nothing when it carries none. Never a newer version from
|
|
156
|
+
* elsewhere — that would make this blob look current.
|
|
157
|
+
*/
|
|
158
|
+
export function writeOptionsFor(record: { version?: unknown } | null | undefined): EntityWriteOptions | undefined {
|
|
159
|
+
return isVersionNumber(record?.version) ? { expectedVersion: record!.version as number } : undefined;
|
|
160
|
+
}
|
|
@@ -32,6 +32,7 @@ export {
|
|
|
32
32
|
} from './review';
|
|
33
33
|
export type {
|
|
34
34
|
CollectionClient,
|
|
35
|
+
EntityWriteOptions,
|
|
35
36
|
EntityRecord,
|
|
36
37
|
EntityTypeDef,
|
|
37
38
|
AttributeDef,
|
|
@@ -52,7 +53,7 @@ export {
|
|
|
52
53
|
SCOPE_FILTER,
|
|
53
54
|
type ScopedCollectionClient,
|
|
54
55
|
} from './scoped-client';
|
|
55
|
-
export { readData, toCamelKey, writeData, declaredBlob, isLossyName } from './data';
|
|
56
|
+
export { readData, toCamelKey, writeData, declaredBlob, isLossyName, isVersionNumber, writeOptionsFor } from './data';
|
|
56
57
|
export {
|
|
57
58
|
parseSources,
|
|
58
59
|
normUrl,
|
|
@@ -150,6 +150,9 @@ export function withScope(
|
|
|
150
150
|
return client.listAllEntities(type, next);
|
|
151
151
|
},
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
// The precondition passes straight through; a call without one stays a
|
|
154
|
+
// two-argument call, so wrapping an old client is not a behaviour change.
|
|
155
|
+
updateEntity: (id, input, options) =>
|
|
156
|
+
options === undefined ? client.updateEntity(id, input) : client.updateEntity(id, input, options),
|
|
154
157
|
};
|
|
155
158
|
}
|
|
@@ -33,6 +33,19 @@ export interface EntityRecord {
|
|
|
33
33
|
name: string;
|
|
34
34
|
data: Record<string, unknown>;
|
|
35
35
|
createdAt: string;
|
|
36
|
+
/** The record's version (bd startsim-3c2wc). Absent on a tenant build that
|
|
37
|
+
* predates the revision trail. */
|
|
38
|
+
version?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* What a write asserts (bd startsim-jkkn7.20). `expectedVersion` is the version
|
|
43
|
+
* of the blob the caller MERGED OVER — not the newest version anybody has seen.
|
|
44
|
+
* A consumer maps it to its precondition (If-Match / expected_version); absent
|
|
45
|
+
* means the caller holds no version and the write goes unguarded, as before.
|
|
46
|
+
*/
|
|
47
|
+
export interface EntityWriteOptions {
|
|
48
|
+
expectedVersion?: number;
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
export interface Paginated<T> {
|
|
@@ -101,5 +114,10 @@ export interface ListEntitiesOptions {
|
|
|
101
114
|
export interface CollectionClient {
|
|
102
115
|
listTypes(): Promise<Paginated<EntityTypeDef>>;
|
|
103
116
|
listAllEntities(type: string, options?: ListEntitiesOptions): Promise<EntityRecord[]>;
|
|
104
|
-
|
|
117
|
+
/** Resolves with the SAVED record, whose `version` the caller moves onto. */
|
|
118
|
+
updateEntity(
|
|
119
|
+
id: number | string,
|
|
120
|
+
input: { name?: string; data?: Record<string, unknown> },
|
|
121
|
+
options?: EntityWriteOptions,
|
|
122
|
+
): Promise<EntityRecord>;
|
|
105
123
|
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FieldAttribution — "edited by Jurga · 2h ago" beside a field, and a machine
|
|
5
|
+
* marked as a machine (bd startsim-vjhbw; read bd startsim-6sso4).
|
|
6
|
+
*
|
|
7
|
+
* The customer's ask was Google-Sheets-like: see who edited which piece and
|
|
8
|
+
* when. The history panel answers "what happened"; this answers the glance —
|
|
9
|
+
* on the field itself, who touched it last — and clicking it opens that field's
|
|
10
|
+
* own history (the panel narrowed with `?field=`).
|
|
11
|
+
*
|
|
12
|
+
* ── THREE READINGS THAT MUST NEVER BE CONFUSED ──────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* 1. AN ENTRY. Say who, through `ActorIdentity` — the same kind marker and the
|
|
15
|
+
* same accessible title as every attributed surface, so a machine reads as a
|
|
16
|
+
* machine here exactly as it does in the panel.
|
|
17
|
+
* 2. NO ENTRY. "Not edited since <the trail began>". Live rows PREDATE the trail
|
|
18
|
+
* (`trail_starts.from_creation: false`), so an absent field means nobody has
|
|
19
|
+
* edited it SINCE history started — never "never edited", which would be a
|
|
20
|
+
* confident claim about a period nobody recorded.
|
|
21
|
+
* 3. HISTORY OFF (`recorded: false`, `fields: null`). We do not know, and say
|
|
22
|
+
* so. Rendering that as (2) would tell a reader nobody edited a field when the
|
|
23
|
+
* truth is that nobody was keeping track.
|
|
24
|
+
*
|
|
25
|
+
* Injected client, like the panel: `{ get: () => fetch(.../field-authors) }`.
|
|
26
|
+
*/
|
|
27
|
+
import type { ReactNode } from 'react';
|
|
28
|
+
import { useQuery } from '@tanstack/react-query';
|
|
29
|
+
import { formatDistanceToNow } from 'date-fns';
|
|
30
|
+
import { Undo2 } from 'lucide-react';
|
|
31
|
+
|
|
32
|
+
import { cn } from '../../lib/utils';
|
|
33
|
+
import { ActorIdentity, type ActorKind, type ActorKindSource } from '../ActorIdentity';
|
|
34
|
+
import type { FieldAuthor, FieldAuthorsClient, FieldAuthorsResponse } from './types';
|
|
35
|
+
|
|
36
|
+
/* -------------------------------------------------------------------------- */
|
|
37
|
+
/* NORMALISATION — either spelling, failing closed on kind */
|
|
38
|
+
/* -------------------------------------------------------------------------- */
|
|
39
|
+
|
|
40
|
+
export interface NormalizedFieldAuthor {
|
|
41
|
+
version?: number;
|
|
42
|
+
at: string;
|
|
43
|
+
actorSub: string;
|
|
44
|
+
actorLabel: string;
|
|
45
|
+
actorKind: ActorKind;
|
|
46
|
+
actorKindSource?: ActorKindSource;
|
|
47
|
+
source: string;
|
|
48
|
+
restoredFrom?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface NormalizedFieldAuthors {
|
|
52
|
+
version?: number;
|
|
53
|
+
/** `false` = history is off for this type; NOTHING may be inferred. */
|
|
54
|
+
recorded: boolean;
|
|
55
|
+
historyEnabled?: boolean;
|
|
56
|
+
trailStarts: { version?: number; at: string; fromCreation: boolean } | null;
|
|
57
|
+
fields: Record<string, NormalizedFieldAuthor>;
|
|
58
|
+
name?: NormalizedFieldAuthor;
|
|
59
|
+
detail?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function pick<T>(...values: Array<T | null | undefined>): T | undefined {
|
|
63
|
+
for (const v of values) if (v !== null && v !== undefined && v !== '') return v;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeAuthor(raw: FieldAuthor | null | undefined): NormalizedFieldAuthor | undefined {
|
|
68
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
69
|
+
const kind = pick(raw.actor_kind, raw.actorKind);
|
|
70
|
+
const source = pick(raw.actor_kind_source, raw.actorKindSource);
|
|
71
|
+
const restored = pick(raw.restored_from, raw.restoredFrom);
|
|
72
|
+
return {
|
|
73
|
+
...(typeof raw.version === 'number' ? { version: raw.version } : {}),
|
|
74
|
+
at: String(pick(raw.at) ?? ''),
|
|
75
|
+
actorSub: String(pick(raw.actor_sub, raw.actorSub) ?? ''),
|
|
76
|
+
actorLabel: String(pick(raw.actor_label, raw.actorLabel) ?? ''),
|
|
77
|
+
// FAIL CLOSED: anything outside the vocabulary is `unknown`, never `person`.
|
|
78
|
+
actorKind: kind === 'person' || kind === 'machine' ? kind : 'unknown',
|
|
79
|
+
...(source === 'claim' || source === 'inferred' ? { actorKindSource: source } : {}),
|
|
80
|
+
source: String(pick(raw.source) ?? ''),
|
|
81
|
+
...(typeof restored === 'number' ? { restoredFrom: restored } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function normalizeFieldAuthors(raw: FieldAuthorsResponse): NormalizedFieldAuthors {
|
|
86
|
+
const r = (raw ?? {}) as FieldAuthorsResponse;
|
|
87
|
+
const ts = pick(r.trail_starts, r.trailStarts);
|
|
88
|
+
const fields: Record<string, NormalizedFieldAuthor> = {};
|
|
89
|
+
for (const [key, value] of Object.entries(r.fields ?? {})) {
|
|
90
|
+
const a = normalizeAuthor(value);
|
|
91
|
+
if (a) fields[key] = a;
|
|
92
|
+
}
|
|
93
|
+
const name = normalizeAuthor(r.name);
|
|
94
|
+
// `recorded` absent on an older build: infer from `fields`, never assume.
|
|
95
|
+
const recorded = typeof r.recorded === 'boolean' ? r.recorded : r.fields != null;
|
|
96
|
+
return {
|
|
97
|
+
...(typeof r.version === 'number' ? { version: r.version } : {}),
|
|
98
|
+
recorded,
|
|
99
|
+
...(typeof r.history?.enabled === 'boolean' ? { historyEnabled: r.history.enabled } : {}),
|
|
100
|
+
trailStarts: ts
|
|
101
|
+
? {
|
|
102
|
+
...(typeof ts.version === 'number' ? { version: ts.version } : {}),
|
|
103
|
+
at: String(ts.at ?? ''),
|
|
104
|
+
fromCreation: (ts.from_creation ?? ts.fromCreation) === true,
|
|
105
|
+
}
|
|
106
|
+
: null,
|
|
107
|
+
fields: recorded ? fields : {},
|
|
108
|
+
...(recorded && name ? { name } : {}),
|
|
109
|
+
...(typeof r.detail === 'string' ? { detail: r.detail } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isNormalized(v: unknown): v is NormalizedFieldAuthors {
|
|
114
|
+
return !!v && typeof v === 'object' && 'trailStarts' in v && 'recorded' in v && !('trail_starts' in v);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function asNormalized(
|
|
118
|
+
authors: FieldAuthorsResponse | NormalizedFieldAuthors | undefined | null,
|
|
119
|
+
): NormalizedFieldAuthors | undefined {
|
|
120
|
+
if (!authors) return undefined;
|
|
121
|
+
return isNormalized(authors) ? authors : normalizeFieldAuthors(authors as FieldAuthorsResponse);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The latest author of one field, or undefined (not edited since the trail
|
|
125
|
+
* began, OR history off — check `recorded` before reading meaning into it). */
|
|
126
|
+
export function fieldAuthorOf(
|
|
127
|
+
authors: FieldAuthorsResponse | NormalizedFieldAuthors | undefined,
|
|
128
|
+
field: string,
|
|
129
|
+
): NormalizedFieldAuthor | undefined {
|
|
130
|
+
const n = asNormalized(authors);
|
|
131
|
+
if (!n || !n.recorded) return undefined;
|
|
132
|
+
return Object.prototype.hasOwnProperty.call(n.fields, field) ? n.fields[field] : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* -------------------------------------------------------------------------- */
|
|
136
|
+
/* THE HOOK */
|
|
137
|
+
/* -------------------------------------------------------------------------- */
|
|
138
|
+
|
|
139
|
+
export interface UseFieldAuthorsOptions {
|
|
140
|
+
client: FieldAuthorsClient;
|
|
141
|
+
/** Put the record's VERSION in this key, so a save re-reads the authors. */
|
|
142
|
+
queryKey: readonly unknown[];
|
|
143
|
+
enabled?: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function useFieldAuthors({ client, queryKey, enabled = true }: UseFieldAuthorsOptions) {
|
|
147
|
+
return useQuery({
|
|
148
|
+
queryKey: [...queryKey],
|
|
149
|
+
queryFn: async () => normalizeFieldAuthors(await client.get()),
|
|
150
|
+
enabled,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* -------------------------------------------------------------------------- */
|
|
155
|
+
/* THE COMPONENTS */
|
|
156
|
+
/* -------------------------------------------------------------------------- */
|
|
157
|
+
|
|
158
|
+
function ago(iso: string): string {
|
|
159
|
+
const d = new Date(iso);
|
|
160
|
+
if (!iso || Number.isNaN(d.getTime())) return '';
|
|
161
|
+
try {
|
|
162
|
+
return formatDistanceToNow(d, { addSuffix: true });
|
|
163
|
+
} catch {
|
|
164
|
+
return '';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function day(iso: string): string {
|
|
169
|
+
const d = new Date(iso);
|
|
170
|
+
return Number.isNaN(d.getTime())
|
|
171
|
+
? ''
|
|
172
|
+
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface FieldAttributionProps {
|
|
176
|
+
/** The field-authors read, raw or normalised. Undefined while loading. */
|
|
177
|
+
authors: FieldAuthorsResponse | NormalizedFieldAuthors | undefined | null;
|
|
178
|
+
/** The data key, exactly as declared. */
|
|
179
|
+
field: string;
|
|
180
|
+
/** Read the NAME column's author instead of `fields[field]`. */
|
|
181
|
+
nameColumn?: boolean;
|
|
182
|
+
/** Open this field's history (the panel narrowed with `?field=`). Omit and the
|
|
183
|
+
* attribution is plain text rather than a button. */
|
|
184
|
+
onOpenHistory?: (field: string) => void;
|
|
185
|
+
/** A URL for "their recent edits" — e.g. `/activity?actor=<sub>`. */
|
|
186
|
+
actorHref?: (actorSub: string) => string;
|
|
187
|
+
/** Rendered as the link when `actorHref` is set. */
|
|
188
|
+
renderLink?: (props: { href: string; className: string; children: ReactNode }) => ReactNode;
|
|
189
|
+
fieldLabel?: (field: string) => string;
|
|
190
|
+
className?: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const ROOT = 'inline-flex flex-wrap items-center gap-x-1 text-xs text-muted-foreground';
|
|
194
|
+
|
|
195
|
+
export function FieldAttribution({
|
|
196
|
+
authors,
|
|
197
|
+
field,
|
|
198
|
+
nameColumn = false,
|
|
199
|
+
onOpenHistory,
|
|
200
|
+
actorHref,
|
|
201
|
+
renderLink,
|
|
202
|
+
fieldLabel = (f) => f,
|
|
203
|
+
className,
|
|
204
|
+
}: FieldAttributionProps) {
|
|
205
|
+
const n = asNormalized(authors);
|
|
206
|
+
if (!n) return null;
|
|
207
|
+
const testId = `field-attribution-${field}`;
|
|
208
|
+
const label = fieldLabel(field);
|
|
209
|
+
|
|
210
|
+
const wrap = (body: ReactNode, title?: string) =>
|
|
211
|
+
onOpenHistory ? (
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
data-testid={testId}
|
|
215
|
+
aria-label={`History of ${label}`}
|
|
216
|
+
title={title}
|
|
217
|
+
onClick={() => onOpenHistory(field)}
|
|
218
|
+
className={cn(ROOT, 'rounded px-0.5 hover:bg-accent hover:text-foreground', className)}
|
|
219
|
+
>
|
|
220
|
+
{body}
|
|
221
|
+
</button>
|
|
222
|
+
) : (
|
|
223
|
+
<span data-testid={testId} title={title} className={cn(ROOT, className)}>
|
|
224
|
+
{body}
|
|
225
|
+
</span>
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (!n.recorded) {
|
|
229
|
+
return wrap(
|
|
230
|
+
<span>Edit history not recorded</span>,
|
|
231
|
+
n.detail ?? 'History is switched off for this kind of record, so who edits it is not recorded.',
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const author = nameColumn ? n.name : n.fields[field];
|
|
236
|
+
if (!author) {
|
|
237
|
+
const ts = n.trailStarts;
|
|
238
|
+
const since = !ts
|
|
239
|
+
? 'No edits recorded'
|
|
240
|
+
: ts.fromCreation
|
|
241
|
+
? 'Not edited since it was created'
|
|
242
|
+
: `Not edited since ${day(ts.at) || `v${ts.version ?? ''}`}`;
|
|
243
|
+
return wrap(
|
|
244
|
+
<span>{since}</span>,
|
|
245
|
+
ts && !ts.fromCreation
|
|
246
|
+
? 'History began after this record was created; edits before then were not recorded.'
|
|
247
|
+
: undefined,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const when = ago(author.at);
|
|
252
|
+
const restored = author.restoredFrom !== undefined;
|
|
253
|
+
const link =
|
|
254
|
+
actorHref && author.actorSub
|
|
255
|
+
? (() => {
|
|
256
|
+
const href = actorHref(author.actorSub);
|
|
257
|
+
const className = 'underline underline-offset-2 hover:text-foreground';
|
|
258
|
+
const children = 'their recent edits';
|
|
259
|
+
return renderLink ? (
|
|
260
|
+
renderLink({ href, className, children })
|
|
261
|
+
) : (
|
|
262
|
+
<a href={href} className={className}>
|
|
263
|
+
{children}
|
|
264
|
+
</a>
|
|
265
|
+
);
|
|
266
|
+
})()
|
|
267
|
+
: null;
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<span className="inline-flex flex-wrap items-center gap-x-1.5">
|
|
271
|
+
{wrap(
|
|
272
|
+
<>
|
|
273
|
+
{restored && <Undo2 className="h-3 w-3" aria-hidden="true" />}
|
|
274
|
+
<span>{restored ? 'Restored by' : 'Edited by'}</span>
|
|
275
|
+
<ActorIdentity
|
|
276
|
+
actor={{ email: author.actorLabel }}
|
|
277
|
+
showInitials={false}
|
|
278
|
+
kind={author.actorKind}
|
|
279
|
+
{...(author.actorKindSource ? { kindSource: author.actorKindSource } : {})}
|
|
280
|
+
className="inline-flex flex-wrap items-center gap-x-1 text-foreground"
|
|
281
|
+
/>
|
|
282
|
+
{restored && <span>from v{author.restoredFrom}</span>}
|
|
283
|
+
{when && <span aria-hidden="true">·</span>}
|
|
284
|
+
{when && <time dateTime={author.at}>{when}</time>}
|
|
285
|
+
</>,
|
|
286
|
+
)}
|
|
287
|
+
{link && <span className="text-xs text-muted-foreground">{link}</span>}
|
|
288
|
+
</span>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The server's sentence when history is off — said ONCE for a form, not per
|
|
293
|
+
* field. Renders nothing when history is recorded. */
|
|
294
|
+
export function FieldAuthorsNotice({
|
|
295
|
+
authors,
|
|
296
|
+
className = 'text-xs text-muted-foreground',
|
|
297
|
+
}: {
|
|
298
|
+
authors: FieldAuthorsResponse | NormalizedFieldAuthors | undefined | null;
|
|
299
|
+
className?: string;
|
|
300
|
+
}) {
|
|
301
|
+
const n = asNormalized(authors);
|
|
302
|
+
if (!n || n.recorded) return null;
|
|
303
|
+
return (
|
|
304
|
+
<p data-testid="field-authors-notice" className={className}>
|
|
305
|
+
{n.detail ?? 'History is switched off for this kind of record, so who edits each field is not recorded.'}
|
|
306
|
+
</p>
|
|
307
|
+
);
|
|
308
|
+
}
|