@startsimpli/ui 0.4.135 → 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 +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- 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,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'
|