@startsimpli/ui 0.4.134 → 0.4.136
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 +5 -4
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/collection/InlineReviewActions.tsx +31 -16
- package/src/components/collection/ReviewDrawer.tsx +15 -2
- package/src/components/collection/__tests__/review.test.ts +149 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +88 -0
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/index.ts +62 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/index.ts +11 -1
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn two revisions of one text field into a git-style unified diff string
|
|
3
|
+
* (bd startsim-g4rwo; originally marketing-agents-web `src/lib/blog-diff.ts`,
|
|
4
|
+
* bd 768w.16.10.5).
|
|
5
|
+
*
|
|
6
|
+
* This is what makes the epic's answer to "which paragraph moved" nearly free
|
|
7
|
+
* rather than a second build: {@link DiffViewer} already renders a RAW unified
|
|
8
|
+
* diff (it splits on `diff --git` headers, colourises `@@` hunks and `+`/`-`
|
|
9
|
+
* lines) and does no diffing itself, so showing a field against its previous
|
|
10
|
+
* value only needs that string synthesised from the two values. Pure and
|
|
11
|
+
* line-based (LCS), so it unit-tests without a backend and adds no dependency.
|
|
12
|
+
*
|
|
13
|
+
* RENAMED FROM `blog-diff` / `unifiedBlogDiff` ON THE WAY IN, deliberately.
|
|
14
|
+
* The algorithm never knew what a blog was — it takes two strings and a path
|
|
15
|
+
* label — and `packages/ui` is consumed by every foundry fork, most of which
|
|
16
|
+
* have no blogs. A shared module carrying one tenant's vocabulary is how
|
|
17
|
+
* surfaces end up unable to reuse it. `path` is what names the field in the
|
|
18
|
+
* rendered header, so a caller passes the field's own name.
|
|
19
|
+
*
|
|
20
|
+
* DO NOT CALL THIS ON A TRUNCATED SIDE. A diff over a silently truncated value
|
|
21
|
+
* shows a deletion that never happened; `canDiffFieldChange` in
|
|
22
|
+
* `revision-history.ts` is the gate, and the panel says why it refused rather
|
|
23
|
+
* than rendering nothing.
|
|
24
|
+
*
|
|
25
|
+
* The algorithm: an LCS edit script over lines, grouped into hunks with a few
|
|
26
|
+
* lines of surrounding context (like `git diff -U<n>`). Identical inputs yield
|
|
27
|
+
* '' so the viewer shows its own empty state rather than an all-context block.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Op = { type: 'eq' | 'del' | 'ins'; line: string };
|
|
31
|
+
|
|
32
|
+
/** Split into lines, tolerating CRLF and a trailing newline (no phantom last line). */
|
|
33
|
+
function toLines(text: string): string[] {
|
|
34
|
+
if (text === '') return [];
|
|
35
|
+
return text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A minimal LCS edit script between two line arrays. O(n*m) DP — a record field is a
|
|
40
|
+
* few hundred lines at most. Returns ops in output order: equal/deleted/inserted.
|
|
41
|
+
*/
|
|
42
|
+
export function diffLines(oldLines: string[], newLines: string[]): Op[] {
|
|
43
|
+
const n = oldLines.length;
|
|
44
|
+
const m = newLines.length;
|
|
45
|
+
// lcs[i][j] = LCS length of oldLines[i:] and newLines[j:].
|
|
46
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
47
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
48
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
49
|
+
lcs[i][j] =
|
|
50
|
+
oldLines[i] === newLines[j]
|
|
51
|
+
? lcs[i + 1][j + 1] + 1
|
|
52
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ops: Op[] = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
let j = 0;
|
|
59
|
+
while (i < n && j < m) {
|
|
60
|
+
if (oldLines[i] === newLines[j]) {
|
|
61
|
+
ops.push({ type: 'eq', line: oldLines[i] });
|
|
62
|
+
i++;
|
|
63
|
+
j++;
|
|
64
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
65
|
+
ops.push({ type: 'del', line: oldLines[i] });
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
ops.push({ type: 'ins', line: newLines[j] });
|
|
69
|
+
j++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
while (i < n) ops.push({ type: 'del', line: oldLines[i++] });
|
|
73
|
+
while (j < m) ops.push({ type: 'ins', line: newLines[j++] });
|
|
74
|
+
return ops;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A prefixed diff body line: ' ' context, '-' deletion, '+' insertion. */
|
|
78
|
+
function prefixed(op: Op): string {
|
|
79
|
+
const mark = op.type === 'eq' ? ' ' : op.type === 'del' ? '-' : '+';
|
|
80
|
+
return `${mark}${op.line}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the unified diff of two text values as a single-file `diff --git` block the
|
|
85
|
+
* DiffViewer can render. `context` is the number of unchanged lines kept around
|
|
86
|
+
* each change (git's default 3). Returns '' when the bodies are identical.
|
|
87
|
+
*/
|
|
88
|
+
export function unifiedTextDiff(
|
|
89
|
+
oldText: string,
|
|
90
|
+
newText: string,
|
|
91
|
+
path = 'value.txt',
|
|
92
|
+
context = 3,
|
|
93
|
+
): string {
|
|
94
|
+
if (oldText === newText) return '';
|
|
95
|
+
const ops = diffLines(toLines(oldText), toLines(newText));
|
|
96
|
+
if (!ops.some((op) => op.type !== 'eq')) return '';
|
|
97
|
+
|
|
98
|
+
// Indices of ops that are actual changes, expanded by `context` on each side and
|
|
99
|
+
// merged into contiguous hunk ranges.
|
|
100
|
+
const changed: number[] = [];
|
|
101
|
+
ops.forEach((op, idx) => {
|
|
102
|
+
if (op.type !== 'eq') changed.push(idx);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const ranges: Array<[number, number]> = [];
|
|
106
|
+
for (const idx of changed) {
|
|
107
|
+
const start = Math.max(0, idx - context);
|
|
108
|
+
const end = Math.min(ops.length - 1, idx + context);
|
|
109
|
+
const last = ranges[ranges.length - 1];
|
|
110
|
+
if (last && start <= last[1] + 1) {
|
|
111
|
+
last[1] = Math.max(last[1], end);
|
|
112
|
+
} else {
|
|
113
|
+
ranges.push([start, end]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines: string[] = [`diff --git a/${path} b/${path}`];
|
|
118
|
+
for (const [start, end] of ranges) {
|
|
119
|
+
// 1-based line numbers of the hunk's first old/new line.
|
|
120
|
+
let oldStart = 1;
|
|
121
|
+
let newStart = 1;
|
|
122
|
+
for (let k = 0; k < start; k++) {
|
|
123
|
+
if (ops[k].type !== 'ins') oldStart++;
|
|
124
|
+
if (ops[k].type !== 'del') newStart++;
|
|
125
|
+
}
|
|
126
|
+
let oldCount = 0;
|
|
127
|
+
let newCount = 0;
|
|
128
|
+
const body: string[] = [];
|
|
129
|
+
for (let k = start; k <= end; k++) {
|
|
130
|
+
const op = ops[k];
|
|
131
|
+
if (op.type !== 'ins') oldCount++;
|
|
132
|
+
if (op.type !== 'del') newCount++;
|
|
133
|
+
body.push(prefixed(op));
|
|
134
|
+
}
|
|
135
|
+
lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
136
|
+
lines.push(...body);
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/history — the shapes the shared record history panel renders
|
|
3
|
+
* (bd startsim-g4rwo; trail bd startsim-o1qib).
|
|
4
|
+
*
|
|
5
|
+
* Structural on purpose, exactly like `activity/types.ts`: packages/ui imports
|
|
6
|
+
* no API client, so these mirror one `/api/v1/entities/<id>/revisions/` row
|
|
7
|
+
* without depending on @startsimpli/api. The consuming app injects its own
|
|
8
|
+
* authed client (see {@link RevisionClient}).
|
|
9
|
+
*
|
|
10
|
+
* ── THE SPELLING TRAP, AND WHY EVERY KEY HERE IS OPTIONAL TWICE ─────────────
|
|
11
|
+
*
|
|
12
|
+
* The wire is snake_case. The shared API client
|
|
13
|
+
* (`packages/api/src/utils/case-transform.ts`) camelCases every response key
|
|
14
|
+
* RECURSIVELY and has no preserve list, so an app that fetches this route
|
|
15
|
+
* through `FetchWrapper` receives `actorKind`, `createdAt`, `redactedFields`
|
|
16
|
+
* and `valuesOmitted`. Both spellings are therefore declared and
|
|
17
|
+
* {@link normalizeRevision} reads either, because a panel that renders blank
|
|
18
|
+
* actors against a camelising client — and correctly against a raw one — is a
|
|
19
|
+
* bug nobody would find twice.
|
|
20
|
+
*
|
|
21
|
+
* WHAT THAT TRANSFORM BREAKS AND THIS MODULE CANNOT FIX: the keys INSIDE
|
|
22
|
+
* `metadata.changed` are the tenant's own declared attribute names, so a
|
|
23
|
+
* camelising client renames `team_verdict` to `teamVerdict` and there is no
|
|
24
|
+
* safe inverse — `snake_to_camel` is not injective, and guessing one would
|
|
25
|
+
* print a field name the tenant never declared. So field names are rendered
|
|
26
|
+
* EXACTLY as received and never re-cased. A consumer that wants the declared
|
|
27
|
+
* spellings must fetch this route without the case transform; one that cannot
|
|
28
|
+
* should pass `fieldLabel` to map them. Stated here rather than discovered
|
|
29
|
+
* later, because the same transform already corrupted declared names once.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The actor vocabulary lives on `ActorIdentity`, which is the component that
|
|
34
|
+
* RENDERS it, and is re-exported here so a consumer of this module needs one
|
|
35
|
+
* import rather than two. Declared once (rule #1): a second union would be a
|
|
36
|
+
* second place for "person | machine | unknown" to drift, and the three-state
|
|
37
|
+
* shape is the one thing in this epic that must not.
|
|
38
|
+
*/
|
|
39
|
+
import type { ActorKind, ActorKindSource } from '../ActorIdentity';
|
|
40
|
+
|
|
41
|
+
export type { ActorKind, ActorKindSource };
|
|
42
|
+
|
|
43
|
+
/** `deleted` is declared server-side and not yet written — bd startsim-jkkn7.6. */
|
|
44
|
+
export type RevisionAction = 'created' | 'updated' | 'deleted';
|
|
45
|
+
|
|
46
|
+
/** Which endpoint wrote. `server` is a management command or a worker — a real
|
|
47
|
+
* write that must not claim to be an endpoint. */
|
|
48
|
+
export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server';
|
|
49
|
+
|
|
50
|
+
/** `none` always, today; `matched` arrives with the conditional write
|
|
51
|
+
* (bd startsim-3c2wc). */
|
|
52
|
+
export type RevisionPrecondition = 'none' | 'matched';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One `metadata.changed` entry, straight off the wire.
|
|
56
|
+
*
|
|
57
|
+
* THE ABSENCE OF A SIDE IS THE PAYLOAD, which is why neither key is nullable
|
|
58
|
+
* rather than optional-and-nullable:
|
|
59
|
+
*
|
|
60
|
+
* - a field the write CREATED has `after` and NO `before` key;
|
|
61
|
+
* - a field the write DELETED has `before` and NO `after` key — the server
|
|
62
|
+
* never writes `after: null`, because a key a person deleted is a different
|
|
63
|
+
* fact from a key set to `null`, and `null` IS a legal stored value;
|
|
64
|
+
* - a field the read gate WITHHELD is `{}` and the row's `redacted_fields`
|
|
65
|
+
* names it;
|
|
66
|
+
* - a row over the byte cap has `{}` for EVERY field and
|
|
67
|
+
* `metadata.values_omitted` set.
|
|
68
|
+
*
|
|
69
|
+
* So `'after' in entry` is the only correct test and `entry.after === undefined`
|
|
70
|
+
* is wrong. {@link normalizeFieldChange} is the one place that does it.
|
|
71
|
+
*/
|
|
72
|
+
export interface RevisionFieldChange {
|
|
73
|
+
before?: unknown;
|
|
74
|
+
after?: unknown;
|
|
75
|
+
/** The value was too large to store. WHEN THIS IS TRUE A MISSING SIDE SAYS
|
|
76
|
+
* NOTHING — it was dropped, not deleted — so the delete inference above is
|
|
77
|
+
* invalid and a diff over this entry would show a deletion that never
|
|
78
|
+
* happened. */
|
|
79
|
+
truncated?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface RevisionMetadata {
|
|
83
|
+
/** ALWAYS PRESENT, possibly `{}`. Keyed by the tenant's declared attribute
|
|
84
|
+
* names — see the spelling trap in this module's header. */
|
|
85
|
+
changed?: Record<string, RevisionFieldChange>;
|
|
86
|
+
/** Present only when the name COLUMN moved. Namespaced away from `changed`
|
|
87
|
+
* because a tenant may declare a data key literally called "name". */
|
|
88
|
+
name?: RevisionFieldChange;
|
|
89
|
+
/** Fields `human_edits.guard_machine_write` HELD BACK. A row carrying these
|
|
90
|
+
* is a machine ATTEMPT, not a change, and its `changed` is `{}` — which is
|
|
91
|
+
* correct, not a bug. */
|
|
92
|
+
preserved?: string[];
|
|
93
|
+
/** What an upsert CLAIMED it was re-authoring. The one override that
|
|
94
|
+
* legitimately destroys a person's text, so it is shown. */
|
|
95
|
+
regenerate?: string[];
|
|
96
|
+
/** The whole row exceeded the row cap: every field is still NAMED in
|
|
97
|
+
* `changed`, every value is `{}`. */
|
|
98
|
+
values_omitted?: boolean;
|
|
99
|
+
/** Camelised alias — see the header. */
|
|
100
|
+
valuesOmitted?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** One row of the append-only revision trail. Only `id` and `version` are
|
|
104
|
+
* relied on; everything else is treated as optional so a row from a tenant
|
|
105
|
+
* running an older build still renders. */
|
|
106
|
+
export interface EntityRevision {
|
|
107
|
+
id: string;
|
|
108
|
+
/** 1-based, GAPLESS 1..N per entity, DB-unique. A hole is a provably lost
|
|
109
|
+
* revision — which is what the unique constraint exists to make visible. */
|
|
110
|
+
version: number;
|
|
111
|
+
action?: RevisionAction | string;
|
|
112
|
+
source?: RevisionSource | string;
|
|
113
|
+
precondition?: RevisionPrecondition | string;
|
|
114
|
+
created_at?: string | null;
|
|
115
|
+
createdAt?: string | null;
|
|
116
|
+
/** May be `""`. */
|
|
117
|
+
actor_sub?: string | null;
|
|
118
|
+
actorSub?: string | null;
|
|
119
|
+
/** The actor's email, denormalised at write time. For a machine this is
|
|
120
|
+
* often not an address at all (`svc:n8n-ogmc`). */
|
|
121
|
+
actor_label?: string | null;
|
|
122
|
+
actorLabel?: string | null;
|
|
123
|
+
actor_kind?: ActorKind | string | null;
|
|
124
|
+
actorKind?: ActorKind | string | null;
|
|
125
|
+
actor_kind_source?: ActorKindSource | string | null;
|
|
126
|
+
actorKindSource?: ActorKindSource | string | null;
|
|
127
|
+
metadata?: RevisionMetadata | null;
|
|
128
|
+
/** PRESENT ONLY when the per-field gate withheld something on this row. */
|
|
129
|
+
redacted_fields?: string[] | null;
|
|
130
|
+
redactedFields?: string[] | null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The DRF envelope. `redacted_fields` is the server's roll-up of every row on
|
|
134
|
+
* this page (`apps/api/pagination.py`), present only when something really
|
|
135
|
+
* was held back. */
|
|
136
|
+
export interface RevisionPage {
|
|
137
|
+
count?: number;
|
|
138
|
+
next?: string | null;
|
|
139
|
+
previous?: string | null;
|
|
140
|
+
results?: EntityRevision[];
|
|
141
|
+
redacted_fields?: string[] | null;
|
|
142
|
+
redactedFields?: string[] | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Narrowing the panel asks for. Server-side paginated (repo rule 8) — the
|
|
146
|
+
* route caps `page_size` at 200 and 400s a parameter it does not recognise,
|
|
147
|
+
* so this stays deliberately minimal. */
|
|
148
|
+
export interface RevisionQuery {
|
|
149
|
+
page?: number;
|
|
150
|
+
pageSize?: number;
|
|
151
|
+
// Index signature so this satisfies an API client's query-params type; the
|
|
152
|
+
// injected client is the app's own.
|
|
153
|
+
[key: string]: unknown;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The one call the panel needs. The consuming app injects its authed client
|
|
158
|
+
* (`{ list: (p) => api.entities.revisions(id, p) }`), which is what keeps
|
|
159
|
+
* @startsimpli/api out of packages/ui and the app wrapper under ten lines.
|
|
160
|
+
*
|
|
161
|
+
* A 404 from this route means the caller may not read the PARENT record — the
|
|
162
|
+
* route is nested and an unreadable row is simply absent. The panel surfaces
|
|
163
|
+
* that as "you cannot see this record's history", never as an empty trail.
|
|
164
|
+
*/
|
|
165
|
+
export interface RevisionClient {
|
|
166
|
+
list: (query: RevisionQuery) => Promise<RevisionPage>;
|
|
167
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -155,7 +155,13 @@ export * from './calendar'
|
|
|
155
155
|
// display name is never printed alone as if it were an identity
|
|
156
156
|
// (startsim-768w.19.8.1).
|
|
157
157
|
export { ActorIdentity, actorInitials, actorLabel, actorHandle } from './ActorIdentity'
|
|
158
|
-
export
|
|
158
|
+
export { actorKindTitle } from './ActorIdentity'
|
|
159
|
+
export type {
|
|
160
|
+
ActorIdentityProps,
|
|
161
|
+
ActorIdentityValue,
|
|
162
|
+
ActorKind,
|
|
163
|
+
ActorKindSource,
|
|
164
|
+
} from './ActorIdentity'
|
|
159
165
|
|
|
160
166
|
// Activity components (timeline, quick log, log dialog)
|
|
161
167
|
export { ActivityTimeline, dayHeading } from './ActivityTimeline'
|
|
@@ -168,6 +174,10 @@ export type {
|
|
|
168
174
|
// The shared, filterable activity feed: filter bar + page-level composer over
|
|
169
175
|
// any append-only event stream (startsim-768w.19.5).
|
|
170
176
|
export * from './activity'
|
|
177
|
+
|
|
178
|
+
// The shared record history panel: who changed which field, from what, to
|
|
179
|
+
// what, and whether a machine did it (bd startsim-g4rwo).
|
|
180
|
+
export * from './history'
|
|
171
181
|
export { QuickLogButtons } from './QuickLogButtons'
|
|
172
182
|
export type { QuickLogButtonsProps, QuickLogAction } from './QuickLogButtons'
|
|
173
183
|
export { LogActivityDialog } from './LogActivityDialog'
|