@startsimpli/ui 0.4.139 → 0.4.141
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 -5
- package/src/components/ActivityTimeline.tsx +17 -12
- package/src/components/ActorIdentity.tsx +15 -2
- package/src/components/charts/TimeSeriesChart.tsx +14 -7
- package/src/components/charts/__tests__/TimeSeriesChart.test.tsx +47 -2
- package/src/components/charts/index.ts +1 -1
- package/src/components/history/RecordHistoryPanel.tsx +54 -18
- package/src/components/history/RevisionFields.tsx +9 -3
- package/src/components/history/__tests__/record-history-panel.test.tsx +157 -9
- package/src/components/history/record-history-panel-default-class-names.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.141",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
50
50
|
"react": "^18.0.0 || ^19.0.0",
|
|
51
51
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
52
|
-
"@startsimpli/
|
|
52
|
+
"@startsimpli/i18n": "^0.1.3",
|
|
53
53
|
"@startsimpli/auth": "^0.4.38",
|
|
54
|
-
"@startsimpli/
|
|
54
|
+
"@startsimpli/api": "^0.5.66"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@startsimpli/api": {
|
|
@@ -126,8 +126,8 @@
|
|
|
126
126
|
"tailwindcss": "^3.4.19",
|
|
127
127
|
"tsup": "^8.5.1",
|
|
128
128
|
"typescript": "^6.0.3",
|
|
129
|
-
"@startsimpli/
|
|
130
|
-
"@startsimpli/
|
|
129
|
+
"@startsimpli/i18n": "0.1.3",
|
|
130
|
+
"@startsimpli/realtime": "0.0.2"
|
|
131
131
|
},
|
|
132
132
|
"keywords": [
|
|
133
133
|
"ui",
|
|
@@ -197,24 +197,29 @@ function TimelineEntry({
|
|
|
197
197
|
{activity.description && (
|
|
198
198
|
<p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
|
|
199
199
|
)}
|
|
200
|
-
{activity.detail && (
|
|
201
|
-
// A DIV, NOT A P. `detail` is typed ReactNode and the record
|
|
202
|
-
// history panel passes a field LIST into it — and a <ul> or a
|
|
203
|
-
// <div> inside a <p> is invalid HTML, which React resolves by
|
|
204
|
-
// reparenting during hydration. That produces a hydration
|
|
205
|
-
// mismatch on every row, and neither tsc nor jsdom reports it:
|
|
206
|
-
// only a real browser does (bd startsim-g4rwo). The classes are
|
|
207
|
-
// unchanged, so every existing caller renders identically.
|
|
208
|
-
<div className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
209
|
-
{activity.detail}
|
|
210
|
-
</div>
|
|
211
|
-
)}
|
|
212
200
|
</div>
|
|
213
201
|
<div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
|
|
214
202
|
{formatTime(activity.occurredAt)}
|
|
215
203
|
</div>
|
|
216
204
|
</div>
|
|
217
205
|
|
|
206
|
+
{activity.detail && (
|
|
207
|
+
// A DIV, NOT A P. `detail` is typed ReactNode and the record history
|
|
208
|
+
// panel passes a field LIST into it — and a <ul> or a <div> inside a
|
|
209
|
+
// <p> is invalid HTML, which React resolves by reparenting during
|
|
210
|
+
// hydration. That produces a hydration mismatch on every row, and
|
|
211
|
+
// neither tsc nor jsdom reports it: only a real browser does
|
|
212
|
+
// (bd startsim-g4rwo).
|
|
213
|
+
//
|
|
214
|
+
// BELOW the title/timestamp row, not inside its left column: there it
|
|
215
|
+
// shared the line with the timestamp and lost ~100px of a 366px
|
|
216
|
+
// review rail to it, which is what left a field's before/after a
|
|
217
|
+
// column of single characters (bd startsim-jkkn7.12).
|
|
218
|
+
<div className={`-mt-1 mb-2 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
219
|
+
{activity.detail}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
218
223
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
219
224
|
<ActorIdentity
|
|
220
225
|
actor={activity.actor}
|
|
@@ -113,8 +113,17 @@ export function actorHandle(actor?: ActorIdentityValue): string {
|
|
|
113
113
|
* panel instead, and only when some loaded row is
|
|
114
114
|
* actually unknown.
|
|
115
115
|
*
|
|
116
|
-
* The truth is never *lost* in the quiet cases
|
|
117
|
-
*
|
|
116
|
+
* The truth is never *lost* in the quiet cases. Whenever a kind is passed, the
|
|
117
|
+
* WRAPPER carries {@link actorKindTitle} as its `title`, on every row and not
|
|
118
|
+
* only on the marker, so hover and the accessible description report it for an
|
|
119
|
+
* unknown or claimed-person row too (bd startsim-jkkn7.15). It is a title and
|
|
120
|
+
* NOT sr-only text on purpose: this chip sits inside sentences
|
|
121
|
+
* ("<actor> changed this record while you were editing"), and a hidden
|
|
122
|
+
* sentence spliced into the middle of one reads worse than no sentence.
|
|
123
|
+
*
|
|
124
|
+
* `person`/`inferred` is unreachable today — the server's inference ladder
|
|
125
|
+
* floors at `unknown` and a believed claim always records `claim` — so its
|
|
126
|
+
* branch below is dead code kept on purpose, as the guard it is.
|
|
118
127
|
*/
|
|
119
128
|
function kindMarker(
|
|
120
129
|
kind: ActorKind | undefined,
|
|
@@ -185,12 +194,16 @@ export function ActorIdentity({
|
|
|
185
194
|
if (!label && !marker) return null;
|
|
186
195
|
const initials = showInitials && label ? actorInitials(actor) : '';
|
|
187
196
|
const handle = actorHandle(actor);
|
|
197
|
+
const kindTitle = kind ? actorKindTitle(kind, kindSource) : undefined;
|
|
188
198
|
|
|
189
199
|
return (
|
|
190
200
|
<span
|
|
191
201
|
data-testid="actor-identity"
|
|
192
202
|
data-actor-kind={kind ?? undefined}
|
|
193
203
|
data-actor-kind-source={kindSource ?? undefined}
|
|
204
|
+
// Only when a kind was passed: every surface that predates the actor axis
|
|
205
|
+
// must render exactly as it did (see the `kind` prop).
|
|
206
|
+
title={kindTitle}
|
|
194
207
|
className={className}
|
|
195
208
|
>
|
|
196
209
|
{initials && <span className={initialsClassName}>{initials}</span>}
|
|
@@ -53,25 +53,32 @@ export interface TimeSeriesChartProps {
|
|
|
53
53
|
/**
|
|
54
54
|
* Series colours, in order, from the theme contract (theme/contract.css) — so
|
|
55
55
|
* they follow the app's brand and flip with `.dark` like every other surface.
|
|
56
|
-
*
|
|
57
|
-
*
|
|
56
|
+
*
|
|
57
|
+
* Categorical only: a series' colour says which series it is, nothing more.
|
|
58
|
+
* `--success` and `--destructive` are deliberately absent — they mean good/bad
|
|
59
|
+
* (gain/loss), and a series that happened to land on one would assert that.
|
|
60
|
+
* `--secondary` and `--accent` are surface tones, too faint to read as a
|
|
61
|
+
* stroke. That leaves three tokens, so distinguishability comes mostly from
|
|
62
|
+
* the dash cycle below.
|
|
58
63
|
*/
|
|
59
64
|
export const SERIES_COLORS = [
|
|
60
65
|
'hsl(var(--primary))',
|
|
61
66
|
'hsl(var(--muted-foreground))',
|
|
62
|
-
'hsl(var(--success, 142.1 76.2% 36.3%))',
|
|
63
|
-
'hsl(var(--destructive))',
|
|
64
67
|
'hsl(var(--foreground))',
|
|
65
68
|
] as const
|
|
66
69
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Each pass through the palette gets the next dash: solid, dashed, dotted,
|
|
72
|
+
* dash-dot. 3 colours x 4 dashes = 12 series before a style repeats. The
|
|
73
|
+
* patterns fit the 16px legend swatch so each one shows its full rhythm.
|
|
74
|
+
*/
|
|
75
|
+
export const SERIES_DASHES = [undefined, '6 4', '2 3', '8 3 2 3'] as const
|
|
69
76
|
|
|
70
77
|
export function seriesStroke(index: number): { color: string; dash: string | undefined } {
|
|
71
78
|
const n = SERIES_COLORS.length
|
|
72
79
|
return {
|
|
73
80
|
color: SERIES_COLORS[index % n],
|
|
74
|
-
dash:
|
|
81
|
+
dash: SERIES_DASHES[Math.floor(index / n) % SERIES_DASHES.length],
|
|
75
82
|
}
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { render, screen } from '@testing-library/react'
|
|
2
|
-
import { TimeSeriesChart, SERIES_COLORS, seriesStroke } from '../TimeSeriesChart'
|
|
2
|
+
import { TimeSeriesChart, SERIES_COLORS, SERIES_DASHES, seriesStroke } from '../TimeSeriesChart'
|
|
3
3
|
import type { TimeSeries } from '../lib/series'
|
|
4
4
|
|
|
5
5
|
// jsdom lays nothing out: recharts' ResponsiveContainer renders no chart at a
|
|
@@ -74,6 +74,19 @@ describe('TimeSeriesChart', () => {
|
|
|
74
74
|
expect(new Set(strokes)).toEqual(new Set([SERIES_COLORS[0], SERIES_COLORS[1]]))
|
|
75
75
|
})
|
|
76
76
|
|
|
77
|
+
it('dashes the series that repeat a colour, on the line and in the legend', () => {
|
|
78
|
+
const four = [0, 1, 2, 3].map((k) => ({ ...holding, name: `S${k}` }))
|
|
79
|
+
const { container } = render(<TimeSeriesChart series={four} unit="$" />)
|
|
80
|
+
const lines = linePaths(container)
|
|
81
|
+
expect(lines).toHaveLength(4)
|
|
82
|
+
expect(lines[3].getAttribute('stroke')).toBe(lines[0].getAttribute('stroke'))
|
|
83
|
+
expect(lines[3].getAttribute('stroke-dasharray')).toBe('6 4')
|
|
84
|
+
expect(lines[0].getAttribute('stroke-dasharray')).toBeNull()
|
|
85
|
+
|
|
86
|
+
const swatches = Array.from(container.querySelectorAll('ul svg line'))
|
|
87
|
+
expect(swatches.map((l) => l.getAttribute('stroke-dasharray'))).toEqual([null, null, null, '6 4'])
|
|
88
|
+
})
|
|
89
|
+
|
|
77
90
|
it('shows a legend for two or more series and none for one', () => {
|
|
78
91
|
const { rerender } = render(<TimeSeriesChart series={[strategy, holding]} unit="$" />)
|
|
79
92
|
expect(screen.getByText('Strategy')).toBeInTheDocument()
|
|
@@ -132,10 +145,42 @@ describe('TimeSeriesChart', () => {
|
|
|
132
145
|
})
|
|
133
146
|
|
|
134
147
|
describe('seriesStroke', () => {
|
|
135
|
-
it('
|
|
148
|
+
it('uses only non-semantic tokens, in order', () => {
|
|
149
|
+
expect([...SERIES_COLORS]).toEqual([
|
|
150
|
+
'hsl(var(--primary))',
|
|
151
|
+
'hsl(var(--muted-foreground))',
|
|
152
|
+
'hsl(var(--foreground))',
|
|
153
|
+
])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('never colours a series as gain or loss', () => {
|
|
157
|
+
// --success / --destructive mean good / bad; a categorical series must not
|
|
158
|
+
// inherit that meaning by landing on one.
|
|
159
|
+
const styles = Array.from({ length: 40 }, (_, i) => seriesStroke(i).color)
|
|
160
|
+
for (const color of styles) {
|
|
161
|
+
expect(color).not.toMatch(/--(success|destructive)\b/)
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('cycles the token palette and steps the dash on each pass', () => {
|
|
136
166
|
const n = SERIES_COLORS.length
|
|
137
167
|
expect(seriesStroke(0)).toEqual({ color: SERIES_COLORS[0], dash: undefined })
|
|
168
|
+
expect(seriesStroke(n - 1)).toEqual({ color: SERIES_COLORS[n - 1], dash: undefined })
|
|
138
169
|
expect(seriesStroke(n)).toEqual({ color: SERIES_COLORS[0], dash: '6 4' })
|
|
139
170
|
expect(seriesStroke(n + 1).color).toBe(SERIES_COLORS[1])
|
|
171
|
+
expect(seriesStroke(2 * n).dash).toBe('2 3')
|
|
172
|
+
expect(seriesStroke(3 * n).dash).toBe('8 3 2 3')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('keeps every series distinct until colours x dashes run out', () => {
|
|
176
|
+
const distinct = SERIES_COLORS.length * SERIES_DASHES.length
|
|
177
|
+
expect(distinct).toBe(12)
|
|
178
|
+
const keys = Array.from({ length: distinct }, (_, i) => {
|
|
179
|
+
const { color, dash } = seriesStroke(i)
|
|
180
|
+
return `${color}|${dash ?? 'solid'}`
|
|
181
|
+
})
|
|
182
|
+
expect(new Set(keys).size).toBe(distinct)
|
|
183
|
+
// Then the cycle starts over.
|
|
184
|
+
expect(seriesStroke(distinct)).toEqual(seriesStroke(0))
|
|
140
185
|
})
|
|
141
186
|
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Charts live behind their own subpath (`@startsimpli/ui/charts`) so recharts
|
|
2
2
|
// is only bundled by apps that draw one.
|
|
3
|
-
export { TimeSeriesChart, SERIES_COLORS, seriesStroke } from './TimeSeriesChart'
|
|
3
|
+
export { TimeSeriesChart, SERIES_COLORS, SERIES_DASHES, seriesStroke } from './TimeSeriesChart'
|
|
4
4
|
export type { TimeSeriesChartProps, TimeSeriesReferenceLine } from './TimeSeriesChart'
|
|
5
5
|
export type {
|
|
6
6
|
TimeSeries,
|
|
@@ -40,14 +40,19 @@
|
|
|
40
40
|
* because this route serves every historical value a record ever held, so it
|
|
41
41
|
* is a richer leak than the row read — and because a `{}` rendered silently
|
|
42
42
|
* reads as data loss.
|
|
43
|
-
* 3.
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
43
|
+
* 3. WHEN HISTORY IS DECLARED OFF FOR THIS KIND OF RECORD. Every write path
|
|
44
|
+
* records a revision now — `annotate`, `move-scope`, `DELETE` and the
|
|
45
|
+
* ingestion worker were the last four, closed by bd startsim-jkkn7.6 — so the
|
|
46
|
+
* old standing caveat that "some writes are not recorded yet" described a gap
|
|
47
|
+
* that no longer exists, and was removed (bd startsim-jkkn7.11). What CAN
|
|
48
|
+
* still leave a trail empty is a DECLARATION: `EntityTypeDef.config.history
|
|
49
|
+
* = {"enabled": false}` on the type (or the reserved `"*"` row). That is a
|
|
50
|
+
* decision somebody made, not a silent door, so it gets different words —
|
|
51
|
+
* "history is not recorded for this kind of record" — and it is said only
|
|
52
|
+
* when the host passes `historyEnabled={false}`. The panel cannot see the
|
|
53
|
+
* setting itself: it only holds a revisions client, and the revisions
|
|
54
|
+
* envelope does not carry the resolved policy. Otherwise the empty state
|
|
55
|
+
* says "no edits recorded" and nothing more — and never "never changed".
|
|
51
56
|
*/
|
|
52
57
|
import { ReactNode, useMemo } from 'react';
|
|
53
58
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
@@ -140,9 +145,19 @@ export interface RecordHistoryPanelProps {
|
|
|
140
145
|
errorTitle?: string;
|
|
141
146
|
notFoundTitle?: string;
|
|
142
147
|
notFoundMessage?: string;
|
|
143
|
-
/**
|
|
144
|
-
* `
|
|
145
|
-
*
|
|
148
|
+
/** Whether this record's TYPE keeps a history, as resolved from
|
|
149
|
+
* `EntityTypeDef.config.history` (the type's own declaration, else the
|
|
150
|
+
* reserved `"*"` row, else on). `false` makes the panel say that history is
|
|
151
|
+
* not recorded for this kind of record, beside any rows written before it
|
|
152
|
+
* was switched off. Omitted or `true` says nothing — the panel never
|
|
153
|
+
* invents a caveat it cannot back. bd startsim-jkkn7.11. */
|
|
154
|
+
historyEnabled?: boolean;
|
|
155
|
+
/** The sentence shown when {@link historyEnabled} is `false`. */
|
|
156
|
+
declaredOffNote?: ReactNode;
|
|
157
|
+
/** An extra caveat under the trail, for a host that knows something about
|
|
158
|
+
* this record's coverage the panel cannot. None by default: since bd
|
|
159
|
+
* startsim-jkkn7.6 every write path records, and a standing "some edits may
|
|
160
|
+
* be missing" would teach readers to discount a complete trail. */
|
|
146
161
|
incompleteNote?: ReactNode;
|
|
147
162
|
classNames?: RecordHistoryPanelClassNames;
|
|
148
163
|
}
|
|
@@ -166,11 +181,14 @@ export function RecordHistoryPanel({
|
|
|
166
181
|
notFoundTitle = 'History is not available to you',
|
|
167
182
|
notFoundMessage =
|
|
168
183
|
'This record, or your access to it, has changed. Reopen the record to see its history.',
|
|
169
|
-
|
|
170
|
-
|
|
184
|
+
historyEnabled,
|
|
185
|
+
declaredOffNote =
|
|
186
|
+
'History is not recorded for this kind of record — it has been switched off for this type, so new edits do not appear here.',
|
|
187
|
+
incompleteNote = null,
|
|
171
188
|
classNames = {},
|
|
172
189
|
}: RecordHistoryPanelProps) {
|
|
173
190
|
const cx = { ...RECORD_HISTORY_PANEL_DEFAULTS, ...classNames };
|
|
191
|
+
const declaredOff = historyEnabled === false;
|
|
174
192
|
|
|
175
193
|
const query = useInfiniteQuery<RevisionPage>({
|
|
176
194
|
queryKey: [...queryKey, { pageSize, windowMs }],
|
|
@@ -214,6 +232,13 @@ export function RecordHistoryPanel({
|
|
|
214
232
|
() => sittings.some((s) => s.actorKind === 'unknown'),
|
|
215
233
|
[sittings],
|
|
216
234
|
);
|
|
235
|
+
// A claimed person is undecorated exactly like an unknown row, so when both
|
|
236
|
+
// are loaded "the rest" would sweep in the one row whose kind WAS recorded
|
|
237
|
+
// (bd startsim-jkkn7.15). Each row's own title/sr-only text says which.
|
|
238
|
+
const anyClaimedPerson = useMemo(
|
|
239
|
+
() => sittings.some((s) => s.actorKind === 'person' && s.actorKindSource === 'claim'),
|
|
240
|
+
[sittings],
|
|
241
|
+
);
|
|
217
242
|
|
|
218
243
|
// Sentence 2 — the server's own roll-up over this page's rows, unioned
|
|
219
244
|
// across the pages loaded so far.
|
|
@@ -245,9 +270,18 @@ export function RecordHistoryPanel({
|
|
|
245
270
|
{anyUnknownKind && (
|
|
246
271
|
<p className={cx.notice} data-testid="record-history-unknown-kind">
|
|
247
272
|
Entries marked <strong className="font-medium text-foreground">machine</strong> were
|
|
248
|
-
made by an automation.
|
|
249
|
-
|
|
250
|
-
|
|
273
|
+
made by an automation.{' '}
|
|
274
|
+
{anyClaimedPerson
|
|
275
|
+
? 'Some unmarked entries were recorded as made by a person; for the others, whether a person or a machine made the edit was not recorded'
|
|
276
|
+
: 'For the rest, whether a person or a machine made the edit was not recorded'}{' '}
|
|
277
|
+
— it is not available for edits made before this was switched on, or by anyone who has
|
|
278
|
+
not signed in since.{anyClaimedPerson && ' Hover a name to see which.'}
|
|
279
|
+
</p>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{declaredOff && !query.isError && (
|
|
283
|
+
<p className={cx.notice} data-testid="record-history-declared-off">
|
|
284
|
+
{declaredOffNote}
|
|
251
285
|
</p>
|
|
252
286
|
)}
|
|
253
287
|
|
|
@@ -277,7 +311,9 @@ export function RecordHistoryPanel({
|
|
|
277
311
|
hasMore={!!query.hasNextPage}
|
|
278
312
|
onLoadMore={loadMore}
|
|
279
313
|
emptyTitle={emptyTitle}
|
|
280
|
-
|
|
314
|
+
// Declared off: "edits will appear here" would be untrue, and the
|
|
315
|
+
// notice above already says why the trail is empty.
|
|
316
|
+
emptyDescription={declaredOff ? '' : emptyDescription}
|
|
281
317
|
activityIcons={DEFAULT_ICONS}
|
|
282
318
|
defaultIcon={Pencil}
|
|
283
319
|
loadMoreLabel={query.isFetchingNextPage ? 'Loading…' : 'Load more'}
|
|
@@ -292,7 +328,7 @@ export function RecordHistoryPanel({
|
|
|
292
328
|
</p>
|
|
293
329
|
)}
|
|
294
330
|
|
|
295
|
-
{/*
|
|
331
|
+
{/* A host-supplied caveat only; none by default (see sentence 3). */}
|
|
296
332
|
{incompleteNote && !query.isError && (
|
|
297
333
|
<p className={cx.countLine} data-testid="record-history-incomplete">
|
|
298
334
|
{incompleteNote}
|
|
@@ -57,7 +57,7 @@ const CLAMP = 'max-h-40 overflow-y-auto whitespace-pre-wrap break-words';
|
|
|
57
57
|
* screen-reader text — never as an empty box that looks like "" was stored. */
|
|
58
58
|
function Side({ label, has, value }: { label: string; has: boolean; value: unknown }) {
|
|
59
59
|
return (
|
|
60
|
-
<div className="min-w-0
|
|
60
|
+
<div className="min-w-0">
|
|
61
61
|
<div className="mb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
|
62
62
|
{label}
|
|
63
63
|
</div>
|
|
@@ -106,7 +106,7 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
return (
|
|
109
|
-
<div className="space-y-2">
|
|
109
|
+
<div className="@container space-y-2">
|
|
110
110
|
{kind === 'truncated' && (
|
|
111
111
|
<p className="flex items-start gap-2 text-xs text-muted-foreground">
|
|
112
112
|
<Scissors className="mt-px h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
@@ -118,7 +118,13 @@ function FieldBody({ field, change }: { field: string; change: FoldedFieldChange
|
|
|
118
118
|
</span>
|
|
119
119
|
</p>
|
|
120
120
|
)}
|
|
121
|
-
|
|
121
|
+
{/* SIZED ON THE CONTAINER, NOT THE VIEWPORT (bd startsim-jkkn7.12). This
|
|
122
|
+
panel is a composer meant to be embedded, and its common home is a
|
|
123
|
+
~370px review rail on a wide screen — where a viewport `sm:` forced the
|
|
124
|
+
sides next to each other and each got a few dozen pixels. Stacked by
|
|
125
|
+
default; side by side only once the field body itself is 24rem wide.
|
|
126
|
+
A grid, not `flex-1` + wrap: a zero flex-basis never wraps. */}
|
|
127
|
+
<div className="grid gap-3 @sm:grid-cols-2">
|
|
122
128
|
<Side label="Before" has={change.hasBefore} value={change.before} />
|
|
123
129
|
<Side label="After" has={change.hasAfter} value={change.after} />
|
|
124
130
|
</div>
|
|
@@ -217,19 +217,83 @@ describe('RecordHistoryPanel — attempts, truncation and completeness', () => {
|
|
|
217
217
|
).toBeInTheDocument()
|
|
218
218
|
})
|
|
219
219
|
|
|
220
|
-
it('NEVER claims the trail is complete', async () => {
|
|
221
|
-
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
222
|
-
const note = await screen.findByTestId('record-history-incomplete')
|
|
223
|
-
expect(note).toHaveTextContent(/not recorded yet/i)
|
|
224
|
-
})
|
|
225
|
-
|
|
226
220
|
it('an empty history says "no edits recorded", not "never changed"', async () => {
|
|
227
221
|
mount(<RecordHistoryPanel client={clientFor(page([]))} />)
|
|
228
222
|
expect(await screen.findByText('No edits recorded for this record')).toBeInTheDocument()
|
|
229
223
|
expect(screen.queryByText(/never been changed|never changed/i)).not.toBeInTheDocument()
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('RecordHistoryPanel — what the trail does and does not cover (bd startsim-jkkn7.11/.12)', () => {
|
|
228
|
+
// bd startsim-jkkn7.6 closed all four silent write paths (annotate,
|
|
229
|
+
// move-scope, DELETE, the ingestion worker). A caveat that the trail misses
|
|
230
|
+
// them now describes a gap that does not exist, and teaches a reader to
|
|
231
|
+
// discount the one surface whose job is to be trusted.
|
|
232
|
+
it('no longer claims bulk imports, scope moves, annotations or deletions go unrecorded', async () => {
|
|
233
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
234
|
+
await screen.findByText('jurga@ogmc.example')
|
|
235
|
+
const panel = screen.getByTestId('record-history-panel')
|
|
236
|
+
expect(panel.textContent).not.toMatch(/bulk imports|scope moves|not recorded yet/i)
|
|
237
|
+
expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('an empty history says "No edits recorded" and nothing more when history is on or unknown', async () => {
|
|
241
|
+
mount(<RecordHistoryPanel client={clientFor(page([]))} />)
|
|
242
|
+
await screen.findByText('No edits recorded for this record')
|
|
243
|
+
expect(screen.queryByTestId('record-history-incomplete')).not.toBeInTheDocument()
|
|
244
|
+
expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('says history is not recorded for this kind of record when the type declares it off', async () => {
|
|
248
|
+
mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled={false} />)
|
|
249
|
+
const note = await screen.findByTestId('record-history-declared-off')
|
|
250
|
+
expect(note).toHaveTextContent(/history is not recorded for this kind of record/i)
|
|
251
|
+
// A declaration, not a defect — it must not read like missing data.
|
|
252
|
+
expect(note.textContent).not.toMatch(/may be missing|error|fail|broken/i)
|
|
253
|
+
// And the empty state must not suggest edits will appear.
|
|
254
|
+
expect(screen.queryByText(/will appear here/i)).not.toBeInTheDocument()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('keeps the declared-off disclosure next to rows recorded before it was switched off', async () => {
|
|
258
|
+
mount(
|
|
259
|
+
<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} historyEnabled={false} />,
|
|
260
|
+
)
|
|
261
|
+
expect(await screen.findByText('jurga@ogmc.example')).toBeInTheDocument()
|
|
262
|
+
expect(screen.getByTestId('record-history-declared-off')).toHaveTextContent(
|
|
263
|
+
/history is not recorded for this kind of record/i,
|
|
264
|
+
)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('says nothing about a declaration when the type keeps history', async () => {
|
|
268
|
+
mount(<RecordHistoryPanel client={clientFor(page([]))} historyEnabled />)
|
|
269
|
+
await screen.findByText('No edits recorded for this record')
|
|
270
|
+
expect(screen.queryByTestId('record-history-declared-off')).not.toBeInTheDocument()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('RevisionFields — the before/after split sizes on its container (bd startsim-jkkn7.12)', () => {
|
|
275
|
+
it('never decides the split from the VIEWPORT', async () => {
|
|
276
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
277
|
+
fireEvent.click(await screen.findByRole('button', { name: /blog/ }))
|
|
278
|
+
const before = screen.getByText('Before')
|
|
279
|
+
const split = before.parentElement!.parentElement!
|
|
280
|
+
// `sm:`/`md:`/`lg:` are viewport media queries: on a wide screen they force
|
|
281
|
+
// the two sides next to each other even inside a 370px rail.
|
|
282
|
+
expect(split.className).not.toMatch(/(^|\s)(sm|md|lg|xl):/)
|
|
283
|
+
// Stacked by default, side by side only when the CONTAINER has room.
|
|
284
|
+
expect(split.className).toMatch(/@sm:/)
|
|
285
|
+
expect(split.closest('.\\@container')).not.toBeNull()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('gives the field list the full card width, not a column beside the timestamp', async () => {
|
|
289
|
+
// Measured in a 366px rail: the detail sat in the header's left column and
|
|
290
|
+
// shared the line with "2 minutes ago", losing ~100px it never needed.
|
|
291
|
+
mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
|
|
292
|
+
const fields = await screen.findByTestId('revision-fields')
|
|
293
|
+
const row = fields.closest('[data-activity-id]')!
|
|
294
|
+
const time = within(row as HTMLElement).getByText(/ago|\d{1,2}:\d{2}/)
|
|
295
|
+
// The timestamp's row must not contain the field list.
|
|
296
|
+
expect(time.parentElement!.contains(fields)).toBe(false)
|
|
233
297
|
})
|
|
234
298
|
})
|
|
235
299
|
|
|
@@ -308,5 +372,89 @@ describe('ActorIdentity — the kind props are additive', () => {
|
|
|
308
372
|
render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" />)
|
|
309
373
|
const el = screen.getByTestId('actor-identity')
|
|
310
374
|
expect(el).toHaveAttribute('data-actor-kind', 'unknown')
|
|
375
|
+
expect(el).toHaveAttribute(
|
|
376
|
+
'title',
|
|
377
|
+
'Whether a person or a machine made this change was not recorded.',
|
|
378
|
+
)
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe('ActorIdentity — every row with a kind can say it (bd startsim-jkkn7.15)', () => {
|
|
383
|
+
it('titles a claimed person, the other undecorated case', () => {
|
|
384
|
+
render(<ActorIdentity actor={{ email: 'malin@ogmc.example' }} kind="person" kindSource="claim" />)
|
|
385
|
+
expect(screen.getByTestId('actor-identity')).toHaveAttribute(
|
|
386
|
+
'title',
|
|
387
|
+
'A person made this change (declared by the sign-in service).',
|
|
388
|
+
)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('titles a marked machine row on the wrapper too', () => {
|
|
392
|
+
render(<ActorIdentity actor={{ email: 'svc@x' }} kind="machine" kindSource="claim" />)
|
|
393
|
+
expect(screen.getByTestId('actor-identity')).toHaveAttribute(
|
|
394
|
+
'title',
|
|
395
|
+
'A machine made this change (declared by the sign-in service).',
|
|
396
|
+
)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('keeps the kind out of the text, because the chip sits inside sentences', () => {
|
|
400
|
+
render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" showInitials={false} />)
|
|
401
|
+
expect(screen.getByTestId('actor-identity')).toHaveTextContent(/^a@b\.com$/)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('adds no title when no kind is passed (older surfaces stay byte-identical)', () => {
|
|
405
|
+
render(<ActorIdentity actor={{ email: 'a@b.com' }} />)
|
|
406
|
+
expect(screen.getByTestId('actor-identity')).not.toHaveAttribute('title')
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
it('titles every row in the panel, including unknown and person/claim', async () => {
|
|
410
|
+
mount(
|
|
411
|
+
<RecordHistoryPanel
|
|
412
|
+
windowMs={0}
|
|
413
|
+
client={clientFor(
|
|
414
|
+
page([
|
|
415
|
+
rev({ version: 4, actor_label: 'u4@x', actor_sub: 'u4', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
|
|
416
|
+
rev({ version: 3, actor_label: 'malin@x', actor_sub: 'u3', created_at: at(-60_000) }),
|
|
417
|
+
rev({ version: 2, actor_label: 'svc:a', actor_sub: 'svc:a', created_at: at(-120_000), actor_kind: 'machine', actor_kind_source: 'inferred' }),
|
|
418
|
+
rev({ version: 1, actor_label: 'svc:b', actor_sub: 'svc:b', created_at: at(-180_000), actor_kind: 'machine', actor_kind_source: 'claim' }),
|
|
419
|
+
]),
|
|
420
|
+
)}
|
|
421
|
+
/>,
|
|
422
|
+
)
|
|
423
|
+
await screen.findByText('malin@x')
|
|
424
|
+
const rows = screen.getAllByTestId('actor-identity')
|
|
425
|
+
expect(rows).toHaveLength(4)
|
|
426
|
+
for (const row of rows) expect(row.getAttribute('title')).toBeTruthy()
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
describe('RecordHistoryPanel — the unrecorded-kind sentence is scoped (bd startsim-jkkn7.15)', () => {
|
|
431
|
+
it('does not sweep a recorded person into "the rest"', async () => {
|
|
432
|
+
mount(
|
|
433
|
+
<RecordHistoryPanel
|
|
434
|
+
windowMs={0}
|
|
435
|
+
client={clientFor(
|
|
436
|
+
page([
|
|
437
|
+
rev({ version: 2, actor_label: 'u2@x', actor_sub: 'u2', actor_kind: 'unknown', actor_kind_source: 'inferred' }),
|
|
438
|
+
rev({ version: 1, actor_label: 'malin@x', actor_sub: 'u1', created_at: at(-60_000) }),
|
|
439
|
+
]),
|
|
440
|
+
)}
|
|
441
|
+
/>,
|
|
442
|
+
)
|
|
443
|
+
const note = await screen.findByTestId('record-history-unknown-kind')
|
|
444
|
+
expect(note.textContent).not.toMatch(/for the rest/i)
|
|
445
|
+
// It says that SOME unmarked entries were recorded as a person, and where
|
|
446
|
+
// to find out which.
|
|
447
|
+
expect(note.textContent).toMatch(/some unmarked entries/i)
|
|
448
|
+
expect(note.textContent).toMatch(/person/i)
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('keeps the plain wording when no loaded row is a recorded person', async () => {
|
|
452
|
+
mount(
|
|
453
|
+
<RecordHistoryPanel
|
|
454
|
+
client={clientFor(page([rev({ version: 1, actor_kind: 'unknown', actor_kind_source: 'inferred' })]))}
|
|
455
|
+
/>,
|
|
456
|
+
)
|
|
457
|
+
const note = await screen.findByTestId('record-history-unknown-kind')
|
|
458
|
+
expect(note.textContent).toMatch(/for the rest/i)
|
|
311
459
|
})
|
|
312
460
|
})
|
|
@@ -10,7 +10,7 @@ export interface RecordHistoryPanelClassNames {
|
|
|
10
10
|
description?: string;
|
|
11
11
|
body?: string;
|
|
12
12
|
/** The panel-level sentences: the unrecorded-kind explanation, the redaction
|
|
13
|
-
* notice and the
|
|
13
|
+
* notice and the declared-off disclosure. */
|
|
14
14
|
notice?: string;
|
|
15
15
|
footer?: string;
|
|
16
16
|
countLine?: string;
|