@prenta/admin 0.93.0 → 0.94.0
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/CHANGELOG.md +111 -0
- package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
- package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-diff.test.js +230 -0
- package/dist/__tests__/lib/version-diff.test.js.map +1 -0
- package/dist/__tests__/lib/version-history.test.d.ts +2 -0
- package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
- package/dist/__tests__/lib/version-history.test.js +125 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
- package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
- package/dist/__tests__/styles/responsive-layer.test.js +127 -0
- package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
- package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
- package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
- package/dist/components/EditorRail.js +3 -3
- package/dist/components/EditorRail.js.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
- package/dist/components/page-tab/DirectiveSwitch.js +5 -1
- package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
- package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
- package/dist/layout/primitives/AdminShell.js +2 -2
- package/dist/layout/primitives/AdminShell.js.map +1 -1
- package/dist/lib/version-diff.d.ts +50 -0
- package/dist/lib/version-diff.d.ts.map +1 -0
- package/dist/lib/version-diff.js +294 -0
- package/dist/lib/version-diff.js.map +1 -0
- package/dist/lib/version-history.d.ts +73 -0
- package/dist/lib/version-history.d.ts.map +1 -0
- package/dist/lib/version-history.js +126 -0
- package/dist/lib/version-history.js.map +1 -0
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +3 -3
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +8 -6
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +115 -32
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/ReviewBar.d.ts +21 -0
- package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
- package/dist/views/page-editor/ReviewBar.js +22 -0
- package/dist/views/page-editor/ReviewBar.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +1 -1
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +1 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
- package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
- package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
- package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/version-diff.test.ts +278 -0
- package/src/__tests__/lib/version-history.test.ts +166 -0
- package/src/__tests__/styles/responsive-layer.test.ts +152 -0
- package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
- package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
- package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
- package/src/components/EditorRail.tsx +5 -5
- package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
- package/src/layout/primitives/AdminShell.tsx +13 -1
- package/src/lib/version-diff.ts +376 -0
- package/src/lib/version-history.ts +192 -0
- package/src/styles/responsive.css +281 -0
- package/src/styles/theme.css +8 -0
- package/src/views/page-editor/EditorCanvas.tsx +34 -7
- package/src/views/page-editor/EditorTopBar.tsx +20 -8
- package/src/views/page-editor/PageSectionEditor.tsx +162 -43
- package/src/views/page-editor/ReviewBar.tsx +76 -0
- package/src/views/page-editor/SectionInspector.tsx +2 -1
- package/src/views/page-editor/SectionsPanel.tsx +2 -2
- package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
changedSectionIds,
|
|
4
|
+
diffVersions,
|
|
5
|
+
formatValue,
|
|
6
|
+
groupChangesBySection,
|
|
7
|
+
} from '../../lib/version-diff.js'
|
|
8
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
9
|
+
|
|
10
|
+
function section(overrides: Partial<PageSection> & { id: string }): PageSection {
|
|
11
|
+
return {
|
|
12
|
+
sectionType: 'hero',
|
|
13
|
+
name: 'Hero Banner',
|
|
14
|
+
visible: true,
|
|
15
|
+
content: {},
|
|
16
|
+
settings: {},
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('formatValue', () => {
|
|
22
|
+
it('renders absent and blank values as an em dash so an empty diff cell is never blank', () => {
|
|
23
|
+
expect(formatValue(null)).toBe('—')
|
|
24
|
+
expect(formatValue(undefined)).toBe('—')
|
|
25
|
+
expect(formatValue('')).toBe('—')
|
|
26
|
+
expect(formatValue(' ')).toBe('—')
|
|
27
|
+
expect(formatValue([])).toBe('—')
|
|
28
|
+
expect(formatValue({})).toBe('—')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('summarises structured values rather than dumping them', () => {
|
|
32
|
+
expect(formatValue([{ value: '1' }, { value: '2' }])).toBe('2 items')
|
|
33
|
+
expect(formatValue([{ value: '1' }])).toBe('1 item')
|
|
34
|
+
expect(formatValue({ name: 'hero.jpg', url: '/m/hero.jpg' })).toBe('hero.jpg')
|
|
35
|
+
// No name-ish key: fall back to a shape description, not "[object Object]".
|
|
36
|
+
expect(formatValue({ x: 1, y: 2 })).toBe('2 fields')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reads booleans and numbers as words the panel can show', () => {
|
|
40
|
+
expect(formatValue(true)).toBe('Yes')
|
|
41
|
+
expect(formatValue(false)).toBe('No')
|
|
42
|
+
expect(formatValue(0)).toBe('0')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('diffVersions', () => {
|
|
47
|
+
it('labels content fields through the section registry, not by raw key', () => {
|
|
48
|
+
const before = [section({ id: 's1', content: { heading: 'Old headline' } })]
|
|
49
|
+
const after = [section({ id: 's1', content: { heading: 'New headline' } })]
|
|
50
|
+
|
|
51
|
+
const changes = diffVersions(after, before)
|
|
52
|
+
|
|
53
|
+
expect(changes).toEqual([
|
|
54
|
+
{
|
|
55
|
+
sectionId: 's1',
|
|
56
|
+
sectionName: 'Hero Banner',
|
|
57
|
+
// 'heading' is `label: 'Heading'` on the hero type in the registry.
|
|
58
|
+
field: 'Heading',
|
|
59
|
+
from: 'Old headline',
|
|
60
|
+
to: 'New headline',
|
|
61
|
+
},
|
|
62
|
+
])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('falls back to the raw key for a type the registry does not describe', () => {
|
|
66
|
+
const before = [section({ id: 's1', sectionType: 'opal-pricing', content: { tiers: 'a' } })]
|
|
67
|
+
const after = [section({ id: 's1', sectionType: 'opal-pricing', content: { tiers: 'b' } })]
|
|
68
|
+
|
|
69
|
+
expect(diffVersions(after, before)[0]?.field).toBe('tiers')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('reports a field cleared to nothing — the key exists only in the older snapshot', () => {
|
|
73
|
+
const before = [section({ id: 's1', content: { heading: 'Something' } })]
|
|
74
|
+
const after = [section({ id: 's1', content: {} })]
|
|
75
|
+
|
|
76
|
+
expect(diffVersions(after, before)).toEqual([
|
|
77
|
+
{ sectionId: 's1', sectionName: 'Hero Banner', field: 'Heading', from: 'Something', to: '—' },
|
|
78
|
+
])
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('reports visibility as a state, because hiding a section is the one structural edit there is', () => {
|
|
82
|
+
const before = [section({ id: 's1', visible: true })]
|
|
83
|
+
const after = [section({ id: 's1', visible: false })]
|
|
84
|
+
|
|
85
|
+
expect(diffVersions(after, before)).toEqual([
|
|
86
|
+
{
|
|
87
|
+
sectionId: 's1',
|
|
88
|
+
sectionName: 'Hero Banner',
|
|
89
|
+
field: 'Visibility',
|
|
90
|
+
from: 'Shown',
|
|
91
|
+
to: 'Hidden',
|
|
92
|
+
},
|
|
93
|
+
])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('reports added and removed sections', () => {
|
|
97
|
+
const before = [section({ id: 's1' })]
|
|
98
|
+
const after = [section({ id: 's1' }), section({ id: 's2', name: 'CTA', sectionType: 'cta' })]
|
|
99
|
+
|
|
100
|
+
expect(diffVersions(after, before)).toEqual([
|
|
101
|
+
{ sectionId: 's2', sectionName: 'CTA', field: 'Section', from: '—', to: 'Added' },
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
expect(diffVersions(before, after)).toEqual([
|
|
105
|
+
{ sectionId: 's2', sectionName: 'CTA', field: 'Section', from: 'Present', to: 'Removed' },
|
|
106
|
+
])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('reports nothing when the snapshots match', () => {
|
|
110
|
+
const same = [section({ id: 's1', content: { heading: 'Same' } })]
|
|
111
|
+
expect(diffVersions(same, [section({ id: 's1', content: { heading: 'Same' } })])).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('reports nothing against a missing predecessor', () => {
|
|
115
|
+
// The oldest version we hold is not the moment the page was created, so
|
|
116
|
+
// reporting every field as "added" would state something false.
|
|
117
|
+
expect(diffVersions([section({ id: 's1', content: { heading: 'x' } })], undefined)).toEqual([])
|
|
118
|
+
expect(diffVersions([section({ id: 's1' })], null)).toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('does not report a settings key that was merely absent versus explicitly default', () => {
|
|
122
|
+
// `{}` and `{ backgroundType: undefined }` format identically, so neither
|
|
123
|
+
// produces a row. Without this the first save after a schema addition
|
|
124
|
+
// would light up every section on the canvas.
|
|
125
|
+
const before = [section({ id: 's1', settings: {} })]
|
|
126
|
+
const after = [section({ id: 's1', settings: { backgroundType: undefined } as never })]
|
|
127
|
+
expect(diffVersions(after, before)).toEqual([])
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('labels known settings and reports a real background change', () => {
|
|
131
|
+
const before = [section({ id: 's1', settings: { backgroundType: 'none' } })]
|
|
132
|
+
const after = [section({ id: 's1', settings: { backgroundType: 'solid' } })]
|
|
133
|
+
|
|
134
|
+
expect(diffVersions(after, before)).toEqual([
|
|
135
|
+
{
|
|
136
|
+
sectionId: 's1',
|
|
137
|
+
sectionName: 'Hero Banner',
|
|
138
|
+
field: 'Background type',
|
|
139
|
+
from: 'none',
|
|
140
|
+
to: 'solid',
|
|
141
|
+
},
|
|
142
|
+
])
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('diffVersions — structured fields', () => {
|
|
147
|
+
const stats = (rows: Array<{ value: string; label: string }>) =>
|
|
148
|
+
section({ id: 's1', sectionType: 'stats', name: 'By The Numbers', content: { stats: rows } })
|
|
149
|
+
|
|
150
|
+
it('catches an edit that keeps the row count', () => {
|
|
151
|
+
// The bug this exists for: deciding "did it change" from the DISPLAY
|
|
152
|
+
// summary collapses an array to "2 items", so retyping a row while keeping
|
|
153
|
+
// the count formats identically on both sides and the field reports no
|
|
154
|
+
// change — no panel row AND no outline on the canvas, which is exactly the
|
|
155
|
+
// edit a reviewer opened history to find.
|
|
156
|
+
const before = [
|
|
157
|
+
stats([
|
|
158
|
+
{ value: '10k', label: 'Users' },
|
|
159
|
+
{ value: '99%', label: 'Uptime' },
|
|
160
|
+
]),
|
|
161
|
+
]
|
|
162
|
+
const after = [
|
|
163
|
+
stats([
|
|
164
|
+
{ value: '20k', label: 'Users' },
|
|
165
|
+
{ value: '99%', label: 'Uptime' },
|
|
166
|
+
]),
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
const changes = diffVersions(after, before)
|
|
170
|
+
|
|
171
|
+
expect(changes).toHaveLength(1)
|
|
172
|
+
expect(changes[0]?.from).toBe('10k')
|
|
173
|
+
expect(changes[0]?.to).toBe('20k')
|
|
174
|
+
// Named down to the row and the sub-field, so "which one" has an answer.
|
|
175
|
+
expect(changes[0]?.field).toBe('Stats · Stat 1 · Value')
|
|
176
|
+
expect(changedSectionIds(changes).has('s1')).toBe(true)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('never emits a row whose before and after both read as a bare count', () => {
|
|
180
|
+
const before = [
|
|
181
|
+
stats([
|
|
182
|
+
{ value: 'a', label: 'x' },
|
|
183
|
+
{ value: 'b', label: 'y' },
|
|
184
|
+
]),
|
|
185
|
+
]
|
|
186
|
+
const after = [
|
|
187
|
+
stats([
|
|
188
|
+
{ value: 'c', label: 'x' },
|
|
189
|
+
{ value: 'd', label: 'y' },
|
|
190
|
+
]),
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
for (const change of diffVersions(after, before)) {
|
|
194
|
+
expect(change.from).not.toBe(change.to)
|
|
195
|
+
expect(change.from).not.toMatch(/^\d+ items?$/)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('reports added and removed rows', () => {
|
|
200
|
+
const before = [stats([{ value: '10k', label: 'Users' }])]
|
|
201
|
+
const after = [
|
|
202
|
+
stats([
|
|
203
|
+
{ value: '10k', label: 'Users' },
|
|
204
|
+
{ value: '99%', label: 'Uptime' },
|
|
205
|
+
]),
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
expect(diffVersions(after, before)).toEqual([
|
|
209
|
+
expect.objectContaining({ field: 'Stats · Stat 2', from: '—', to: 'Added' }),
|
|
210
|
+
])
|
|
211
|
+
expect(diffVersions(before, after)).toEqual([
|
|
212
|
+
expect.objectContaining({ field: 'Stats · Stat 2', from: 'Present', to: 'Removed' }),
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('catches a nested change inside a media object', () => {
|
|
217
|
+
// Same failure one level down: two media objects with the same `name` used
|
|
218
|
+
// to format identically while pointing at different files.
|
|
219
|
+
const media = (url: string) => ({ name: 'hero.jpg', url })
|
|
220
|
+
const before = [section({ id: 's1', content: { imageUrl: media('/a.jpg') } })]
|
|
221
|
+
const after = [section({ id: 's1', content: { imageUrl: media('/b.jpg') } })]
|
|
222
|
+
|
|
223
|
+
expect(diffVersions(after, before)).toHaveLength(1)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('treats every spelling of blank as unchanged', () => {
|
|
227
|
+
// Tightening the comparison must not start reporting "— → —".
|
|
228
|
+
for (const [before, after] of [
|
|
229
|
+
['', null],
|
|
230
|
+
[null, undefined],
|
|
231
|
+
[undefined, ' '],
|
|
232
|
+
[[], undefined],
|
|
233
|
+
[{}, null],
|
|
234
|
+
] as Array<[unknown, unknown]>) {
|
|
235
|
+
const changes = diffVersions(
|
|
236
|
+
[section({ id: 's1', content: { heading: after } })],
|
|
237
|
+
[section({ id: 's1', content: { heading: before } })],
|
|
238
|
+
)
|
|
239
|
+
expect(changes, `${JSON.stringify(before)} → ${JSON.stringify(after)}`).toEqual([])
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('reports an unchanged repeater as no change at all', () => {
|
|
244
|
+
const rows = [{ value: '10k', label: 'Users' }]
|
|
245
|
+
expect(diffVersions([stats(rows)], [stats([...rows.map((r) => ({ ...r }))])])).toEqual([])
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe('groupChangesBySection', () => {
|
|
250
|
+
it('groups by section in first-seen order', () => {
|
|
251
|
+
const before = [
|
|
252
|
+
section({ id: 's1', content: { heading: 'a', body: 'a' } }),
|
|
253
|
+
section({ id: 's2', name: 'CTA', sectionType: 'cta', content: { heading: 'a' } }),
|
|
254
|
+
]
|
|
255
|
+
const after = [
|
|
256
|
+
section({ id: 's1', content: { heading: 'b', body: 'b' } }),
|
|
257
|
+
section({ id: 's2', name: 'CTA', sectionType: 'cta', content: { heading: 'b' } }),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
const groups = groupChangesBySection(diffVersions(after, before))
|
|
261
|
+
|
|
262
|
+
expect(groups.map((g) => g.sectionId)).toEqual(['s1', 's2'])
|
|
263
|
+
expect(groups[0]?.changes).toHaveLength(2)
|
|
264
|
+
expect(groups[1]?.changes).toHaveLength(1)
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
describe('changedSectionIds', () => {
|
|
269
|
+
it('collapses several changes on one section to a single outline', () => {
|
|
270
|
+
const before = [section({ id: 's1', content: { heading: 'a', body: 'a' } })]
|
|
271
|
+
const after = [section({ id: 's1', content: { heading: 'b', body: 'b' } })]
|
|
272
|
+
|
|
273
|
+
const ids = changedSectionIds(diffVersions(after, before))
|
|
274
|
+
|
|
275
|
+
expect(ids.size).toBe(1)
|
|
276
|
+
expect(ids.has('s1')).toBe(true)
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
dayLabel,
|
|
4
|
+
groupVersionsByDay,
|
|
5
|
+
initials,
|
|
6
|
+
toPageVersion,
|
|
7
|
+
versionKind,
|
|
8
|
+
type PageVersion,
|
|
9
|
+
} from '../../lib/version-history.js'
|
|
10
|
+
|
|
11
|
+
const NOW = new Date('2026-08-02T17:00:00Z')
|
|
12
|
+
|
|
13
|
+
function version(id: string, createdAt: string): PageVersion {
|
|
14
|
+
return {
|
|
15
|
+
id,
|
|
16
|
+
changeType: 'UPDATE',
|
|
17
|
+
createdAt,
|
|
18
|
+
author: { id: 'u1', name: 'Sarah Mills' },
|
|
19
|
+
sections: [],
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('toPageVersion', () => {
|
|
24
|
+
it('lifts the section snapshot out of the version payload', () => {
|
|
25
|
+
const mapped = toPageVersion({
|
|
26
|
+
id: 'v1',
|
|
27
|
+
changeType: 'PUBLISH',
|
|
28
|
+
createdAt: '2026-08-02T12:00:00Z',
|
|
29
|
+
data: {
|
|
30
|
+
sections: [
|
|
31
|
+
{ id: 's1', sectionType: 'hero', name: 'Hero', visible: true, content: {}, settings: {} },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
changedBy: { id: 'u1', name: 'Sarah Mills', email: 's@example.com' },
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(mapped.sections).toHaveLength(1)
|
|
38
|
+
expect(mapped.sections[0]?.id).toBe('s1')
|
|
39
|
+
expect(mapped.changeType).toBe('PUBLISH')
|
|
40
|
+
expect(mapped.author.name).toBe('Sarah Mills')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('preserves an unmanaged section rather than dropping it from the snapshot', () => {
|
|
44
|
+
// Coercing a snapshot more strictly than the live document reads would
|
|
45
|
+
// make an externally-managed section look deleted in every version.
|
|
46
|
+
const mapped = toPageVersion({
|
|
47
|
+
id: 'v1',
|
|
48
|
+
changeType: 'UPDATE',
|
|
49
|
+
createdAt: '2026-08-02T12:00:00Z',
|
|
50
|
+
data: {
|
|
51
|
+
sections: [
|
|
52
|
+
{ id: 's1', sectionType: 'opal-pricing', name: 'Pricing', visible: true, content: {} },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(mapped.sections).toHaveLength(1)
|
|
58
|
+
expect(mapped.sections[0]?.sectionType).toBe('opal-pricing')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('falls back through name → email → a real word for the avatar', () => {
|
|
62
|
+
expect(
|
|
63
|
+
toPageVersion({ id: 'v', changeType: 'UPDATE', createdAt: '', data: null }).author.name,
|
|
64
|
+
).toBe('Unknown')
|
|
65
|
+
|
|
66
|
+
expect(
|
|
67
|
+
toPageVersion({
|
|
68
|
+
id: 'v',
|
|
69
|
+
changeType: 'UPDATE',
|
|
70
|
+
createdAt: '',
|
|
71
|
+
data: null,
|
|
72
|
+
changedBy: { id: 'u', name: ' ', email: 'a@b.com' },
|
|
73
|
+
}).author.name,
|
|
74
|
+
).toBe('a@b.com')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('yields no sections for a version whose payload has none', () => {
|
|
78
|
+
expect(
|
|
79
|
+
toPageVersion({ id: 'v', changeType: 'DELETE', createdAt: '', data: {} }).sections,
|
|
80
|
+
).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('versionKind', () => {
|
|
85
|
+
it('marks a published snapshot as the one a visitor could have seen', () => {
|
|
86
|
+
expect(versionKind('PUBLISH')).toEqual({ label: 'Published', tone: 'live' })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('calls UPDATE "Saved", never "Draft"', () => {
|
|
90
|
+
// Every row written before cms-core started recording PUBLISH is an UPDATE
|
|
91
|
+
// whether or not it went live, so "Draft" would be false for those.
|
|
92
|
+
expect(versionKind('UPDATE').label).toBe('Saved')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('distinguishes a rollback from authored content', () => {
|
|
96
|
+
expect(versionKind('RESTORE').label).toBe('Restored')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('degrades to a neutral label for an unrecognised type', () => {
|
|
100
|
+
expect(versionKind('SOMETHING_NEW').label).toBe('Saved')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('dayLabel', () => {
|
|
105
|
+
it('names today and yesterday', () => {
|
|
106
|
+
expect(dayLabel('2026-08-02T09:00:00', NOW)).toBe('Today')
|
|
107
|
+
expect(dayLabel('2026-08-01T23:59:00', NOW)).toBe('Yesterday')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('drops the year within the current one and keeps it outside', () => {
|
|
111
|
+
expect(dayLabel('2026-07-27T09:00:00', NOW)).not.toMatch(/2026/)
|
|
112
|
+
expect(dayLabel('2025-07-27T09:00:00', NOW)).toMatch(/2025/)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('does not throw on an unparseable timestamp', () => {
|
|
116
|
+
expect(dayLabel('not-a-date', NOW)).toBe('Unknown date')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('groupVersionsByDay', () => {
|
|
121
|
+
it('groups consecutive runs and keeps newest-first order', () => {
|
|
122
|
+
const groups = groupVersionsByDay(
|
|
123
|
+
[
|
|
124
|
+
version('v5', '2026-08-02T16:12:00'),
|
|
125
|
+
version('v4', '2026-08-02T11:03:00'),
|
|
126
|
+
version('v3', '2026-08-01T17:47:00'),
|
|
127
|
+
version('v1', '2026-07-27T09:30:00'),
|
|
128
|
+
],
|
|
129
|
+
NOW,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
expect(groups.map((g) => g.label)).toEqual(['Today', 'Yesterday', expect.stringMatching(/Jul/)])
|
|
133
|
+
expect(groups[0]?.versions.map((v) => v.id)).toEqual(['v5', 'v4'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('does not merge two non-adjacent runs that share a label', () => {
|
|
137
|
+
// A Map keyed on the label would silently reorder an unsorted page into
|
|
138
|
+
// something that reads as chronological but is not.
|
|
139
|
+
const groups = groupVersionsByDay(
|
|
140
|
+
[
|
|
141
|
+
version('a', '2026-08-02T16:00:00'),
|
|
142
|
+
version('b', '2026-08-01T16:00:00'),
|
|
143
|
+
version('c', '2026-08-02T09:00:00'),
|
|
144
|
+
],
|
|
145
|
+
NOW,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
expect(groups.map((g) => g.label)).toEqual(['Today', 'Yesterday', 'Today'])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('returns nothing for an empty list', () => {
|
|
152
|
+
expect(groupVersionsByDay([], NOW)).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('initials', () => {
|
|
157
|
+
it('takes first and last for a full name, two letters for one word', () => {
|
|
158
|
+
expect(initials('Sarah Mills')).toBe('SM')
|
|
159
|
+
expect(initials('Sarah Jane Mills')).toBe('SM')
|
|
160
|
+
expect(initials('sarah')).toBe('SA')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('never renders an empty monogram', () => {
|
|
164
|
+
expect(initials(' ')).toBe('?')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The responsive + accessibility layer (`src/styles/responsive.css`), ported
|
|
7
|
+
* from the design bundle's `design-system/cms-responsive.css`.
|
|
8
|
+
*
|
|
9
|
+
* Every way this breaks is invisible: a stylesheet that stops being imported,
|
|
10
|
+
* a rule whose class hook was renamed in a component, or a token override
|
|
11
|
+
* placed before the token it overrides all produce a page that renders
|
|
12
|
+
* perfectly and simply lacks the behaviour. None of it raises an error, and
|
|
13
|
+
* none of it shows up in a screenshot taken at a desktop width with a mouse.
|
|
14
|
+
*
|
|
15
|
+
* Reads the source rather than a compiled bundle so the suite does not depend
|
|
16
|
+
* on a build step; the entry-point assertion is what ties the two together.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PKG = fileURLToPath(new URL('../../../', import.meta.url))
|
|
20
|
+
const read = (rel: string) => readFileSync(PKG + rel, 'utf8')
|
|
21
|
+
|
|
22
|
+
const responsive = read('src/styles/responsive.css')
|
|
23
|
+
const theme = read('src/styles/theme.css')
|
|
24
|
+
const tokens = read('src/styles/prenta-tokens.css')
|
|
25
|
+
|
|
26
|
+
describe('responsive + accessibility layer', () => {
|
|
27
|
+
it('is imported by theme.css, which both entry points pull in', () => {
|
|
28
|
+
// `build-input.css` (precompiled) and `tailwind.css` (advanced) each
|
|
29
|
+
// import theme.css and nothing else of ours, so importing from there is
|
|
30
|
+
// what puts this layer in front of every consumer.
|
|
31
|
+
expect(theme).toContain("@import './responsive.css'")
|
|
32
|
+
expect(read('src/styles/build-input.css')).toContain("@import './theme.css'")
|
|
33
|
+
expect(read('src/styles/tailwind.css')).toContain("@import './theme.css'")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('is imported AFTER the token block it overrides', () => {
|
|
37
|
+
// Same specificity, so source order alone decides. Imported first, the
|
|
38
|
+
// prefers-contrast block would parse, match, and change nothing.
|
|
39
|
+
expect(theme.indexOf("@import './prenta-tokens.css'")).toBeLessThan(
|
|
40
|
+
theme.indexOf("@import './responsive.css'"),
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('scopes every rule to the admin root', () => {
|
|
45
|
+
// The admin renders inside consumer applications. A bare `:focus-visible`
|
|
46
|
+
// or `@media print` rule here would restyle the host's own pages.
|
|
47
|
+
const selectors = responsive
|
|
48
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => line.trim())
|
|
51
|
+
.filter((line) => line.endsWith('{') && !line.startsWith('@'))
|
|
52
|
+
|
|
53
|
+
expect(selectors.length).toBeGreaterThan(10)
|
|
54
|
+
for (const selector of selectors) {
|
|
55
|
+
expect(selector, `unscoped selector: ${selector}`).toMatch(/\.prenta-admin/)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('raises contrast rather than lowering it, in both themes', () => {
|
|
60
|
+
// The shipped --muted is already an AA correction over the handoff's own
|
|
61
|
+
// value (see prenta-tokens.css). A "more contrast" preference that moved
|
|
62
|
+
// it the other way would be worse than not honouring the preference.
|
|
63
|
+
const luminance = (hex: string) => {
|
|
64
|
+
const n = parseInt(hex.slice(1), 16)
|
|
65
|
+
return ((n >> 16) & 255) * 0.299 + ((n >> 8) & 255) * 0.587 + (n & 255) * 0.114
|
|
66
|
+
}
|
|
67
|
+
const declaration = (css: string, name: string, from = 0) => {
|
|
68
|
+
const match = new RegExp(`${name}:\\s*(#[0-9a-f]{6})`, 'i').exec(css.slice(from))
|
|
69
|
+
return match?.[1] ?? null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const contrastBlock = responsive.slice(responsive.indexOf('@media (prefers-contrast: more)'))
|
|
73
|
+
const darkStart = contrastBlock.indexOf('[data-theme=')
|
|
74
|
+
|
|
75
|
+
for (const name of ['--muted', '--sub', '--bdr']) {
|
|
76
|
+
const base = declaration(tokens, name)!
|
|
77
|
+
const raised = declaration(contrastBlock.slice(0, darkStart), name)!
|
|
78
|
+
// Light theme: darker text and borders on a light surface.
|
|
79
|
+
expect(luminance(raised), `light ${name}`).toBeLessThan(luminance(base))
|
|
80
|
+
|
|
81
|
+
const darkBase = declaration(
|
|
82
|
+
tokens,
|
|
83
|
+
name,
|
|
84
|
+
tokens.indexOf(".prenta-admin[data-theme='dark']"),
|
|
85
|
+
)!
|
|
86
|
+
const darkRaised = declaration(contrastBlock.slice(darkStart), name)!
|
|
87
|
+
// Dark theme: lighter, on a dark surface.
|
|
88
|
+
expect(luminance(darkRaised), `dark ${name}`).toBeGreaterThan(luminance(darkBase))
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('grows touch targets only under a coarse pointer', () => {
|
|
93
|
+
const coarse = responsive.slice(
|
|
94
|
+
responsive.indexOf('@media (pointer: coarse)'),
|
|
95
|
+
responsive.indexOf('@media (min-width: 2000px)'),
|
|
96
|
+
)
|
|
97
|
+
expect(coarse).toContain('min-height: 44px')
|
|
98
|
+
expect(coarse).toContain('min-width: 44px')
|
|
99
|
+
// A 26px button is right with a mouse. No control floor is imposed outside
|
|
100
|
+
// this block — the 44px that appears in the stacked-layout rules is the
|
|
101
|
+
// collapsed strip's own height, not a minimum applied to a control.
|
|
102
|
+
const elsewhere = responsive.replace(coarse, '').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
103
|
+
expect(elsewhere).not.toMatch(/min-(height|width):\s*44px/)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('grows the switch by its hit area, never by its box', () => {
|
|
107
|
+
// A switch positions its thumb against the track. Stretching the track to
|
|
108
|
+
// 44px leaves the thumb floating in dead space — the one carve-out the
|
|
109
|
+
// design bundle makes, and for this reason.
|
|
110
|
+
const hit = responsive.slice(responsive.indexOf('.prenta-touch-hit::after'))
|
|
111
|
+
expect(hit).toContain('position: absolute')
|
|
112
|
+
expect(hit.slice(0, hit.indexOf('}'))).not.toContain('min-height')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('overrides the rail width with !important, because it is set inline', () => {
|
|
116
|
+
// The rail is drag-resizable and its width is an inline style, which no
|
|
117
|
+
// class can beat. Without this the stacked layout silently keeps a
|
|
118
|
+
// 360px-wide rail on a 390px screen.
|
|
119
|
+
const stacked = responsive.slice(responsive.indexOf('@media (max-width: 900px)'))
|
|
120
|
+
expect(stacked).toMatch(/\.prenta-editor-rail\s*\{[^}]*width:\s*100%\s*!important/)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('class hooks the layer targets', () => {
|
|
125
|
+
// A rule whose hook was renamed in a component is dead CSS with no error.
|
|
126
|
+
const hooks: Array<[string, string]> = [
|
|
127
|
+
['prenta-skip-link', 'src/layout/primitives/AdminShell.tsx'],
|
|
128
|
+
['prenta-editor-rail', 'src/components/EditorRail.tsx'],
|
|
129
|
+
['prenta-editor-rail-collapsed', 'src/components/EditorRail.tsx'],
|
|
130
|
+
['prenta-rail-label', 'src/components/EditorRail.tsx'],
|
|
131
|
+
['prenta-editor-body', 'src/views/page-editor/PageSectionEditor.tsx'],
|
|
132
|
+
['prenta-editor-header', 'src/views/page-editor/EditorTopBar.tsx'],
|
|
133
|
+
['prenta-editor-inspector', 'src/views/page-editor/SectionInspector.tsx'],
|
|
134
|
+
['prenta-canvas-scroll', 'src/views/page-editor/EditorCanvas.tsx'],
|
|
135
|
+
['prenta-canvas-fit', 'src/views/page-editor/EditorCanvas.tsx'],
|
|
136
|
+
['prenta-touch-target', 'src/components/EditorRail.tsx'],
|
|
137
|
+
['prenta-touch-hit', 'src/components/page-tab/DirectiveSwitch.tsx'],
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
for (const [hook, file] of hooks) {
|
|
141
|
+
it(`${hook} is applied in ${file.split('/').pop()}`, () => {
|
|
142
|
+
expect(responsive, `${hook} is unused in responsive.css`).toContain(hook)
|
|
143
|
+
expect(read(file), `${hook} is not applied by any component`).toContain(hook)
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
it('points the skip link at a target that exists', () => {
|
|
148
|
+
const shell = read('src/layout/primitives/AdminShell.tsx')
|
|
149
|
+
expect(shell).toContain('href="#prenta-main"')
|
|
150
|
+
expect(shell).toContain('id="prenta-main"')
|
|
151
|
+
})
|
|
152
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import { SectionRenderer } from '../../views/page-editor/sections/SectionRenderer.js'
|
|
5
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup)
|
|
8
|
+
|
|
9
|
+
const SECTION: PageSection = {
|
|
10
|
+
id: 's1',
|
|
11
|
+
sectionType: 'hero',
|
|
12
|
+
name: 'Hero Banner',
|
|
13
|
+
visible: true,
|
|
14
|
+
content: { heading: 'A headline' },
|
|
15
|
+
settings: {},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sectionEl(): HTMLElement {
|
|
19
|
+
const el = document.querySelector('[data-section-id="s1"]')
|
|
20
|
+
if (!el) throw new Error('section not rendered')
|
|
21
|
+
return el as HTMLElement
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('SectionRenderer — review mode', () => {
|
|
25
|
+
it('marks a changed section and names the change in its label', () => {
|
|
26
|
+
render(<SectionRenderer section={SECTION} editable reviewing changed />)
|
|
27
|
+
|
|
28
|
+
expect(screen.getByText('Hero Banner — changed')).toBeTruthy()
|
|
29
|
+
expect(sectionEl().getAttribute('data-changed')).toBe('true')
|
|
30
|
+
expect(sectionEl().className).toContain('outline-dashed')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('leaves an unchanged section unlabelled and unoutlined', () => {
|
|
34
|
+
render(<SectionRenderer section={SECTION} editable reviewing changed={false} />)
|
|
35
|
+
|
|
36
|
+
expect(screen.queryByText(/— changed/)).toBeNull()
|
|
37
|
+
expect(sectionEl().getAttribute('data-changed')).toBeNull()
|
|
38
|
+
expect(sectionEl().className).not.toContain('outline-dashed')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps the changed label even though review mode suppresses editing chrome', () => {
|
|
42
|
+
// The trap the handoff calls out by name: review mode and site-preview
|
|
43
|
+
// mode both drop the cursor, the hover outline and the section label, so
|
|
44
|
+
// it is tempting to share one suppression rule. Doing that hides the one
|
|
45
|
+
// label review mode exists to show.
|
|
46
|
+
render(<SectionRenderer section={SECTION} editable reviewing changed selected />)
|
|
47
|
+
|
|
48
|
+
expect(sectionEl().getAttribute('data-editable')).toBeNull()
|
|
49
|
+
expect(sectionEl().className).not.toContain('cursor-pointer')
|
|
50
|
+
// The selection label is gone…
|
|
51
|
+
expect(screen.queryByText('Hero Banner')).toBeNull()
|
|
52
|
+
// …and the changed label is not.
|
|
53
|
+
expect(screen.getByText('Hero Banner — changed')).toBeTruthy()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('does not select a section while reviewing — that would edit the current page', () => {
|
|
57
|
+
const onSelect = vi.fn()
|
|
58
|
+
render(<SectionRenderer section={SECTION} editable reviewing changed onSelect={onSelect} />)
|
|
59
|
+
|
|
60
|
+
fireEvent.click(sectionEl())
|
|
61
|
+
|
|
62
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('still selects normally when not reviewing', () => {
|
|
66
|
+
const onSelect = vi.fn()
|
|
67
|
+
render(<SectionRenderer section={SECTION} editable onSelect={onSelect} />)
|
|
68
|
+
|
|
69
|
+
fireEvent.click(sectionEl())
|
|
70
|
+
|
|
71
|
+
expect(onSelect).toHaveBeenCalledWith('s1')
|
|
72
|
+
expect(sectionEl().getAttribute('data-editable')).toBe('true')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('names the change in the accessible name, not only in colour and a dashed border', () => {
|
|
76
|
+
render(<SectionRenderer section={SECTION} editable reviewing changed />)
|
|
77
|
+
expect(sectionEl().getAttribute('aria-label')).toBe(
|
|
78
|
+
'Hero Banner section — changed in this version',
|
|
79
|
+
)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('keeps a hidden section muted in a snapshot, as it was when saved', () => {
|
|
83
|
+
render(
|
|
84
|
+
<SectionRenderer
|
|
85
|
+
section={{ ...SECTION, visible: false }}
|
|
86
|
+
editable
|
|
87
|
+
reviewing
|
|
88
|
+
changed={false}
|
|
89
|
+
/>,
|
|
90
|
+
)
|
|
91
|
+
expect(sectionEl().className).toContain('opacity-40')
|
|
92
|
+
expect(screen.getByText('Hidden')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
})
|