@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,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fold, and what one field change SAYS (bd startsim-g4rwo).
|
|
3
|
+
*
|
|
4
|
+
* Every case here is one of the server contract's edge cases that a naive
|
|
5
|
+
* reading gets wrong — absence vs null, truncation gating the delete
|
|
6
|
+
* inference, `{}` meaning three different things, and the four fold refusals.
|
|
7
|
+
* Pure module, so none of it needs a render or a backend.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
COLLAPSE_WINDOW_MS,
|
|
11
|
+
actorKindOf,
|
|
12
|
+
actorKindSourceOf,
|
|
13
|
+
canDiffFieldChange,
|
|
14
|
+
classifyFieldChange,
|
|
15
|
+
foldRevisions,
|
|
16
|
+
normalizeFieldChange,
|
|
17
|
+
normalizeRevision,
|
|
18
|
+
sittingHeadline,
|
|
19
|
+
sittingSummary,
|
|
20
|
+
} from '../revision-history'
|
|
21
|
+
import type { EntityRevision } from '../types'
|
|
22
|
+
|
|
23
|
+
const T0 = Date.parse('2026-10-05T12:00:00.000Z')
|
|
24
|
+
const at = (msAfter: number) => new Date(T0 + msAfter).toISOString()
|
|
25
|
+
|
|
26
|
+
function rev(over: Partial<EntityRevision> & { version: number }): EntityRevision {
|
|
27
|
+
return {
|
|
28
|
+
id: `r${over.version}`,
|
|
29
|
+
action: 'updated',
|
|
30
|
+
source: 'patch',
|
|
31
|
+
precondition: 'none',
|
|
32
|
+
created_at: at(0),
|
|
33
|
+
actor_sub: 'u1',
|
|
34
|
+
actor_label: 'jurga@ogmc.example',
|
|
35
|
+
actor_kind: 'person',
|
|
36
|
+
actor_kind_source: 'claim',
|
|
37
|
+
metadata: { changed: {} },
|
|
38
|
+
...over,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('actorKindOf — three-state, failing closed', () => {
|
|
43
|
+
it('reads person and machine', () => {
|
|
44
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'person' }))).toBe('person')
|
|
45
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'machine' }))).toBe('machine')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reads an ABSENT kind as unknown, never as person', () => {
|
|
49
|
+
expect(actorKindOf({ id: 'x', version: 1 })).toBe('unknown')
|
|
50
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: null }))).toBe('unknown')
|
|
51
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: '' }))).toBe('unknown')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('reads a value from outside the vocabulary as unknown', () => {
|
|
55
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'robot' }))).toBe('unknown')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reads the CAMELISED spelling the shared API client produces', () => {
|
|
59
|
+
expect(actorKindOf({ id: 'x', version: 1, actorKind: 'machine' })).toBe('machine')
|
|
60
|
+
expect(actorKindSourceOf({ id: 'x', version: 1, actorKindSource: 'inferred' })).toBe('inferred')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('distinguishes a claimed kind from an inferred one', () => {
|
|
64
|
+
expect(actorKindSourceOf(rev({ version: 1, actor_kind_source: 'claim' }))).toBe('claim')
|
|
65
|
+
expect(actorKindSourceOf(rev({ version: 1, actor_kind_source: 'inferred' }))).toBe('inferred')
|
|
66
|
+
expect(actorKindSourceOf({ id: 'x', version: 1 })).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('normalizeFieldChange — absence is the payload', () => {
|
|
71
|
+
it('a CREATED key has after and no before', () => {
|
|
72
|
+
const c = normalizeFieldChange({ after: 'hello' })
|
|
73
|
+
expect(c.hasBefore).toBe(false)
|
|
74
|
+
expect(c.hasAfter).toBe(true)
|
|
75
|
+
expect(classifyFieldChange(c)).toBe('created')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a DELETED key has before and no after', () => {
|
|
79
|
+
const c = normalizeFieldChange({ before: 'gone' })
|
|
80
|
+
expect(c.hasAfter).toBe(false)
|
|
81
|
+
expect(classifyFieldChange(c)).toBe('deleted')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('a key set to NULL is an update, not a deletion — null is a real value', () => {
|
|
85
|
+
const c = normalizeFieldChange({ before: 'x', after: null })
|
|
86
|
+
expect(c.hasAfter).toBe(true)
|
|
87
|
+
expect(c.after).toBeNull()
|
|
88
|
+
expect(classifyFieldChange(c)).toBe('updated')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a key created AS null is created, not deleted', () => {
|
|
92
|
+
const c = normalizeFieldChange({ after: null })
|
|
93
|
+
expect(classifyFieldChange(c)).toBe('created')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a WITHHELD key is {} and classifies as withheld, not as a deletion', () => {
|
|
97
|
+
expect(classifyFieldChange(normalizeFieldChange({}, { withheld: true }))).toBe('withheld')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('an OMITTED row’s key is {} and classifies as omitted', () => {
|
|
101
|
+
expect(classifyFieldChange(normalizeFieldChange({}, { omitted: true }))).toBe('omitted')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('TRUNCATION WINS over the delete inference — a missing side says nothing', () => {
|
|
105
|
+
// before present, after absent, but truncated: this is NOT a deletion.
|
|
106
|
+
const c = normalizeFieldChange({ before: 'a very long prefix', truncated: true })
|
|
107
|
+
expect(classifyFieldChange(c)).toBe('truncated')
|
|
108
|
+
expect(classifyFieldChange(c)).not.toBe('deleted')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('refuses to diff a truncated, withheld or omitted side', () => {
|
|
112
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: 'a', after: 'b', truncated: true }))).toBe(false)
|
|
113
|
+
expect(canDiffFieldChange(normalizeFieldChange({}, { withheld: true }))).toBe(false)
|
|
114
|
+
expect(canDiffFieldChange(normalizeFieldChange({}, { omitted: true }))).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('diffs only when both sides are text', () => {
|
|
118
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: 'a', after: 'b' }))).toBe(true)
|
|
119
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: { a: 1 }, after: { a: 2 } }))).toBe(false)
|
|
120
|
+
expect(canDiffFieldChange(normalizeFieldChange({ after: 'new' }))).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('normalizeRevision', () => {
|
|
125
|
+
it('marks a field named in redacted_fields as withheld', () => {
|
|
126
|
+
const n = normalizeRevision(
|
|
127
|
+
rev({ version: 3, metadata: { changed: { body: {} } }, redacted_fields: ['body'] }),
|
|
128
|
+
)
|
|
129
|
+
expect(n.changed.body!.withheld).toBe(true)
|
|
130
|
+
expect(n.redacted).toEqual(['body'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('marks every field omitted when the ROW was over the cap', () => {
|
|
134
|
+
const n = normalizeRevision(
|
|
135
|
+
rev({ version: 3, metadata: { changed: { a: {}, b: {} }, values_omitted: true } }),
|
|
136
|
+
)
|
|
137
|
+
expect(n.valuesOmitted).toBe(true)
|
|
138
|
+
expect(n.changed.a!.omitted).toBe(true)
|
|
139
|
+
expect(n.changed.b!.omitted).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('reads values_omitted in its camelised spelling too', () => {
|
|
143
|
+
const n = normalizeRevision(rev({ version: 1, metadata: { changed: { a: {} }, valuesOmitted: true } }))
|
|
144
|
+
expect(n.valuesOmitted).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a PRESERVED row with no changes is an ATTEMPT', () => {
|
|
148
|
+
const n = normalizeRevision(
|
|
149
|
+
rev({ version: 4, metadata: { changed: {}, preserved: ['blog'] } }),
|
|
150
|
+
)
|
|
151
|
+
expect(n.isAttempt).toBe(true)
|
|
152
|
+
expect(n.preserved).toEqual(['blog'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a row that both changed and preserved something is NOT an attempt', () => {
|
|
156
|
+
const n = normalizeRevision(
|
|
157
|
+
rev({ version: 4, metadata: { changed: { seo: { after: 'x' } }, preserved: ['blog'] } }),
|
|
158
|
+
)
|
|
159
|
+
expect(n.isAttempt).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('survives a row with no metadata at all', () => {
|
|
163
|
+
const n = normalizeRevision({ id: 'x', version: 1 })
|
|
164
|
+
expect(n.changed).toEqual({})
|
|
165
|
+
expect(n.isAttempt).toBe(false)
|
|
166
|
+
expect(n.actorKind).toBe('unknown')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('foldRevisions', () => {
|
|
171
|
+
it('folds consecutive same-actor saves inside the window into one sitting', () => {
|
|
172
|
+
const rows = [
|
|
173
|
+
rev({ version: 3, created_at: at(4000), metadata: { changed: { blog: { before: 'b', after: 'c' } } } }),
|
|
174
|
+
rev({ version: 2, created_at: at(2000), metadata: { changed: { blog: { before: 'a', after: 'b' } } } }),
|
|
175
|
+
rev({ version: 1, created_at: at(0), action: 'created', metadata: { changed: { blog: { after: 'a' } } } }),
|
|
176
|
+
]
|
|
177
|
+
const folded = foldRevisions(rows)
|
|
178
|
+
// The create does not absorb the later edits.
|
|
179
|
+
expect(folded).toHaveLength(2)
|
|
180
|
+
expect(folded[0]!.saves).toBe(2)
|
|
181
|
+
expect(folded[1]!.action).toBe('created')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('takes BEFORE from the oldest row and AFTER from the newest', () => {
|
|
185
|
+
const rows = [
|
|
186
|
+
rev({ version: 3, created_at: at(4000), metadata: { changed: { blog: { before: 'two', after: 'three' } } } }),
|
|
187
|
+
rev({ version: 2, created_at: at(2000), metadata: { changed: { blog: { before: 'one', after: 'two' } } } }),
|
|
188
|
+
]
|
|
189
|
+
const [sitting] = foldRevisions(rows)
|
|
190
|
+
expect(sitting!.changed.blog!.before).toBe('one')
|
|
191
|
+
expect(sitting!.changed.blog!.after).toBe('three')
|
|
192
|
+
expect(sitting!.fromVersion).toBe(2)
|
|
193
|
+
expect(sitting!.toVersion).toBe(3)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not fold across the window', () => {
|
|
197
|
+
const rows = [
|
|
198
|
+
rev({ version: 2, created_at: at(COLLAPSE_WINDOW_MS + 1000) }),
|
|
199
|
+
rev({ version: 1, created_at: at(0) }),
|
|
200
|
+
]
|
|
201
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not fold two different actors', () => {
|
|
205
|
+
const rows = [
|
|
206
|
+
rev({ version: 2, created_at: at(1000), actor_sub: 'u2', actor_label: 'b@x' }),
|
|
207
|
+
rev({ version: 1, created_at: at(0), actor_sub: 'u1', actor_label: 'a@x' }),
|
|
208
|
+
]
|
|
209
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('REFUSES to fold across a differing actor_kind even when the sub matches', () => {
|
|
213
|
+
// The healing window: one person's rows read unknown then person. Folding
|
|
214
|
+
// would stamp the sitting with whichever end won.
|
|
215
|
+
const rows = [
|
|
216
|
+
rev({ version: 2, created_at: at(1000), actor_kind: 'person', actor_kind_source: 'claim' }),
|
|
217
|
+
rev({ version: 1, created_at: at(0), actor_kind: 'unknown', actor_kind_source: 'inferred' }),
|
|
218
|
+
]
|
|
219
|
+
const folded = foldRevisions(rows)
|
|
220
|
+
expect(folded).toHaveLength(2)
|
|
221
|
+
expect(folded[0]!.actorKind).toBe('person')
|
|
222
|
+
expect(folded[1]!.actorKind).toBe('unknown')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('REFUSES to fold across a differing actor_kind_source', () => {
|
|
226
|
+
const rows = [
|
|
227
|
+
rev({ version: 2, created_at: at(1000), actor_kind_source: 'claim' }),
|
|
228
|
+
rev({ version: 1, created_at: at(0), actor_kind_source: 'inferred' }),
|
|
229
|
+
]
|
|
230
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('REFUSES to fold an ATTEMPT into a change', () => {
|
|
234
|
+
const rows = [
|
|
235
|
+
rev({
|
|
236
|
+
version: 2,
|
|
237
|
+
created_at: at(1000),
|
|
238
|
+
actor_sub: 'svc:n8n',
|
|
239
|
+
actor_kind: 'machine',
|
|
240
|
+
actor_kind_source: 'inferred',
|
|
241
|
+
metadata: { changed: {}, preserved: ['blog'] },
|
|
242
|
+
}),
|
|
243
|
+
rev({
|
|
244
|
+
version: 1,
|
|
245
|
+
created_at: at(0),
|
|
246
|
+
actor_sub: 'svc:n8n',
|
|
247
|
+
actor_kind: 'machine',
|
|
248
|
+
actor_kind_source: 'inferred',
|
|
249
|
+
metadata: { changed: { seo: { after: 'x' } } },
|
|
250
|
+
}),
|
|
251
|
+
]
|
|
252
|
+
const folded = foldRevisions(rows)
|
|
253
|
+
expect(folded).toHaveLength(2)
|
|
254
|
+
expect(folded[0]!.isAttempt).toBe(true)
|
|
255
|
+
expect(folded[1]!.isAttempt).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('folds two attempts together', () => {
|
|
259
|
+
const rows = [
|
|
260
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: {}, preserved: ['seo'] } }),
|
|
261
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: {}, preserved: ['blog'] } }),
|
|
262
|
+
]
|
|
263
|
+
const folded = foldRevisions(rows)
|
|
264
|
+
expect(folded).toHaveLength(1)
|
|
265
|
+
expect(folded[0]!.preserved).toEqual(['blog', 'seo'])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('carries a withheld flag FORWARD through a fold rather than dropping it', () => {
|
|
269
|
+
const rows = [
|
|
270
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: { body: { before: 'x', after: 'y' } } } }),
|
|
271
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: { body: {} } }, redacted_fields: ['body'] }),
|
|
272
|
+
]
|
|
273
|
+
const [sitting] = foldRevisions(rows)
|
|
274
|
+
expect(sitting!.saves).toBe(2)
|
|
275
|
+
expect(sitting!.changed.body!.withheld).toBe(true)
|
|
276
|
+
expect(sitting!.redacted).toEqual(['body'])
|
|
277
|
+
expect(canDiffFieldChange(sitting!.changed.body!)).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('carries a truncated flag forward, so a fold never produces a clean diff from a capped side', () => {
|
|
281
|
+
const rows = [
|
|
282
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: { blog: { before: 'b', after: 'c' } } } }),
|
|
283
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: { blog: { before: 'a', truncated: true } } } }),
|
|
284
|
+
]
|
|
285
|
+
const [sitting] = foldRevisions(rows)
|
|
286
|
+
expect(sitting!.changed.blog!.truncated).toBe(true)
|
|
287
|
+
expect(canDiffFieldChange(sitting!.changed.blog!)).toBe(false)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('windowMs of 0 renders a row per save', () => {
|
|
291
|
+
const rows = [
|
|
292
|
+
rev({ version: 2, created_at: at(1000) }),
|
|
293
|
+
rev({ version: 1, created_at: at(0) }),
|
|
294
|
+
]
|
|
295
|
+
expect(foldRevisions(rows, 0)).toHaveLength(2)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('treats an out-of-order clock as the same sitting rather than inventing a precedence', () => {
|
|
299
|
+
const rows = [rev({ version: 2, created_at: at(0) }), rev({ version: 1, created_at: at(0) })]
|
|
300
|
+
expect(foldRevisions(rows)).toHaveLength(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns nothing for an empty trail', () => {
|
|
304
|
+
expect(foldRevisions([])).toEqual([])
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('sittingSummary / sittingHeadline', () => {
|
|
309
|
+
const sit = (rows: EntityRevision[]) => foldRevisions(rows)[0]!
|
|
310
|
+
|
|
311
|
+
it('a single save does not claim to be a sitting', () => {
|
|
312
|
+
expect(sittingSummary(sit([rev({ version: 1 })]))).toBe('1 save')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('a collapsed sitting says it is a collapse, with its span', () => {
|
|
316
|
+
const rows = [
|
|
317
|
+
rev({ version: 2, created_at: at(120_000) }),
|
|
318
|
+
rev({ version: 1, created_at: at(0) }),
|
|
319
|
+
]
|
|
320
|
+
expect(sittingSummary(sit(rows))).toBe('2 saves over 2 min')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('a sub-minute burst reports the count without a misleading 0 min', () => {
|
|
324
|
+
const rows = [rev({ version: 2, created_at: at(3000) }), rev({ version: 1, created_at: at(0) })]
|
|
325
|
+
expect(sittingSummary(sit(rows))).toBe('2 saves')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('names the fields it edited', () => {
|
|
329
|
+
const s = sit([rev({ version: 1, metadata: { changed: { blog: { after: 'x' }, seo: { after: 'y' } } } })])
|
|
330
|
+
expect(sittingHeadline(s)).toBe('Edited blog and seo')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('counts instead of listing beyond three fields', () => {
|
|
334
|
+
const changed = Object.fromEntries(['a', 'b', 'c', 'd'].map((k) => [k, { after: '1' }]))
|
|
335
|
+
const s = sit([rev({ version: 1, metadata: { changed } })])
|
|
336
|
+
expect(sittingHeadline(s)).toBe('Edited 4 fields')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('AN ATTEMPT READS AS AN ATTEMPT — the sentence nobody could produce', () => {
|
|
340
|
+
const s = sit([
|
|
341
|
+
rev({ version: 1, metadata: { changed: {}, preserved: ['team_verdict'] } }),
|
|
342
|
+
])
|
|
343
|
+
expect(sittingHeadline(s)).toBe('Tried to write team_verdict — held back')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('a name-only move reads as a rename', () => {
|
|
347
|
+
const s = sit([rev({ version: 2, metadata: { changed: {}, name: { before: 'a', after: 'b' } } })])
|
|
348
|
+
expect(sittingHeadline(s)).toBe('Renamed the record')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('applies a fieldLabel mapping', () => {
|
|
352
|
+
const s = sit([rev({ version: 1, metadata: { changed: { team_verdict: { after: 'x' } } } })])
|
|
353
|
+
expect(sittingHeadline(s, (f) => f.replace('_', ' '))).toBe('Edited team verdict')
|
|
354
|
+
})
|
|
355
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ported from marketing-agents-web `src/lib/__tests__/blog-diff.test.ts` with
|
|
3
|
+
* the module (bd startsim-g4rwo). Same assertions, jest instead of vitest, and
|
|
4
|
+
* the generic `unifiedTextDiff` name — see `text-diff.ts` on the rename.
|
|
5
|
+
*/
|
|
6
|
+
import { parseDiffFiles } from '../../diff'
|
|
7
|
+
import { diffLines, unifiedTextDiff } from '../text-diff'
|
|
8
|
+
|
|
9
|
+
describe('diffLines', () => {
|
|
10
|
+
it('marks equal / deleted / inserted lines in output order', () => {
|
|
11
|
+
expect(diffLines(['a', 'b', 'c'], ['a', 'x', 'c'])).toEqual([
|
|
12
|
+
{ type: 'eq', line: 'a' },
|
|
13
|
+
{ type: 'del', line: 'b' },
|
|
14
|
+
{ type: 'ins', line: 'x' },
|
|
15
|
+
{ type: 'eq', line: 'c' },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('unifiedTextDiff', () => {
|
|
21
|
+
it('returns "" for identical values', () => {
|
|
22
|
+
expect(unifiedTextDiff('same\ntext', 'same\ntext')).toBe('')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('emits a git-style block the shared DiffViewer parser can read', () => {
|
|
26
|
+
const diff = unifiedTextDiff(
|
|
27
|
+
'Line one\nLine two\nLine three',
|
|
28
|
+
'Line one\nLine 2\nLine three',
|
|
29
|
+
'blog',
|
|
30
|
+
)
|
|
31
|
+
expect(diff).toContain('diff --git a/blog b/blog')
|
|
32
|
+
expect(diff).toMatch(/^@@ -\d+,\d+ \+\d+,\d+ @@$/m)
|
|
33
|
+
expect(diff).toContain('-Line two')
|
|
34
|
+
expect(diff).toContain('+Line 2')
|
|
35
|
+
const files = parseDiffFiles(diff)
|
|
36
|
+
expect(files).toHaveLength(1)
|
|
37
|
+
expect(files[0]!.filePath).toBe('blog')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps context lines but not the whole document', () => {
|
|
41
|
+
const oldText = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n')
|
|
42
|
+
const newText = oldText.replace('line 10', 'LINE TEN')
|
|
43
|
+
const body = unifiedTextDiff(oldText, newText)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((l) => /^[ +-]/.test(l) && !l.startsWith('+++') && !l.startsWith('---'))
|
|
46
|
+
expect(body).toContain('-line 10')
|
|
47
|
+
expect(body).toContain('+LINE TEN')
|
|
48
|
+
expect(body).toContain(' line 7')
|
|
49
|
+
expect(body).not.toContain(' line 0')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('names the field in the header, so the rendered diff says what moved', () => {
|
|
53
|
+
expect(unifiedTextDiff('a', 'b', 'linkedin')).toContain('diff --git a/linkedin b/linkedin')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tolerates CRLF and a trailing newline without inventing a last line', () => {
|
|
57
|
+
expect(unifiedTextDiff('a\r\nb\r\n', 'a\nb\n')).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/history — the shared record history panel (bd startsim-g4rwo).
|
|
3
|
+
*
|
|
4
|
+
* "Who edited what piece of the text and when", over the append-only revision
|
|
5
|
+
* trail (bd startsim-o1qib). A page-level composer plus the pure helpers it
|
|
6
|
+
* folds with, so a foundry fork holds an injected client and its theme
|
|
7
|
+
* classNames rather than its own timeline, its own fold and its own idea of
|
|
8
|
+
* what `{}` means (rule #9).
|
|
9
|
+
*
|
|
10
|
+
* THE ONE THING A CONSUMER MUST NOT GET WRONG: `actorKind` is THREE-state, and
|
|
11
|
+
* `unknown` is the common case for genuine human edits for up to 14 days after
|
|
12
|
+
* rollout. Render it through this panel — or through `ActorIdentity` with
|
|
13
|
+
* `kind`/`kindSource` — rather than deciding locally, because "absent" read as
|
|
14
|
+
* "a machine did this" is the single worst failure available here.
|
|
15
|
+
*/
|
|
16
|
+
export { RecordHistoryPanel, REVISION_ENTRY_TYPES } from './RecordHistoryPanel';
|
|
17
|
+
export type { RecordHistoryPanelProps } from './RecordHistoryPanel';
|
|
18
|
+
|
|
19
|
+
export { RevisionFields } from './RevisionFields';
|
|
20
|
+
export type { RevisionFieldsProps } from './RevisionFields';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
RECORD_HISTORY_PANEL_DEFAULTS,
|
|
24
|
+
type RecordHistoryPanelClassNames,
|
|
25
|
+
} from './record-history-panel-default-class-names';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
COLLAPSE_WINDOW_MS,
|
|
29
|
+
actorKindOf,
|
|
30
|
+
actorKindSourceOf,
|
|
31
|
+
canDiffFieldChange,
|
|
32
|
+
classifyFieldChange,
|
|
33
|
+
foldRevisions,
|
|
34
|
+
joinNames,
|
|
35
|
+
normalizeFieldChange,
|
|
36
|
+
normalizeRevision,
|
|
37
|
+
sittingFields,
|
|
38
|
+
sittingHeadline,
|
|
39
|
+
sittingSummary,
|
|
40
|
+
} from './revision-history';
|
|
41
|
+
export type {
|
|
42
|
+
FieldChangeKind,
|
|
43
|
+
FoldedFieldChange,
|
|
44
|
+
NormalizedRevision,
|
|
45
|
+
RevisionSitting,
|
|
46
|
+
} from './revision-history';
|
|
47
|
+
|
|
48
|
+
export { diffLines, unifiedTextDiff } from './text-diff';
|
|
49
|
+
|
|
50
|
+
export type {
|
|
51
|
+
ActorKind,
|
|
52
|
+
ActorKindSource,
|
|
53
|
+
EntityRevision,
|
|
54
|
+
RevisionAction,
|
|
55
|
+
RevisionClient,
|
|
56
|
+
RevisionFieldChange,
|
|
57
|
+
RevisionMetadata,
|
|
58
|
+
RevisionPage,
|
|
59
|
+
RevisionPrecondition,
|
|
60
|
+
RevisionQuery,
|
|
61
|
+
RevisionSource,
|
|
62
|
+
} from './types';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link RecordHistoryPanel}. bd startsim-g4rwo.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe, and no
|
|
3
|
+
* literal colour, so a dark tenant theme recolours this without a fork. */
|
|
4
|
+
|
|
5
|
+
export interface RecordHistoryPanelClassNames {
|
|
6
|
+
root?: string;
|
|
7
|
+
header?: string;
|
|
8
|
+
heading?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
body?: string;
|
|
12
|
+
/** The panel-level sentences: the unrecorded-kind explanation, the redaction
|
|
13
|
+
* notice and the "some write paths are not recorded" caveat. */
|
|
14
|
+
notice?: string;
|
|
15
|
+
footer?: string;
|
|
16
|
+
countLine?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RECORD_HISTORY_PANEL_DEFAULTS: Required<RecordHistoryPanelClassNames> = {
|
|
20
|
+
root: 'rounded-lg border border-border bg-card',
|
|
21
|
+
header: 'flex flex-wrap items-start justify-between gap-2 border-b border-border px-5 py-3',
|
|
22
|
+
heading: 'space-y-1',
|
|
23
|
+
title: 'font-medium text-foreground',
|
|
24
|
+
description: 'text-xs text-muted-foreground',
|
|
25
|
+
body: 'max-h-[32rem] overflow-y-auto px-5 py-4',
|
|
26
|
+
notice: 'border-b border-border px-5 py-2 text-xs text-muted-foreground',
|
|
27
|
+
footer: 'border-t border-border px-5 py-3 text-sm',
|
|
28
|
+
countLine: 'border-t border-border px-5 py-2 text-xs text-muted-foreground',
|
|
29
|
+
};
|