@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.
@@ -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
+ }
@@ -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 type { ActorIdentityProps, ActorIdentityValue } from './ActorIdentity'
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'