@prenta/admin 0.93.0 → 0.95.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 +152 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
- 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/section-edit-form.render.test.d.ts +2 -0
- package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
- package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
- package/dist/__tests__/views/section-inspector-media.test.js +1 -1
- package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
- package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
- package/dist/__tests__/views/section-inspector-repeater.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/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- 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 +129 -40
- 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/SectionEditForm.d.ts +42 -0
- package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
- package/dist/views/page-editor/SectionEditForm.js +48 -0
- package/dist/views/page-editor/SectionEditForm.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +0 -20
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
- package/dist/views/page-editor/SectionInspector.js +6 -224
- package/dist/views/page-editor/SectionInspector.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +11 -5
- 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/section-fields.d.ts +43 -0
- package/dist/views/page-editor/section-fields.d.ts.map +1 -0
- package/dist/views/page-editor/section-fields.js +236 -0
- package/dist/views/page-editor/section-fields.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
- 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/dist/views/post-editor/PostEditorCanvas.js +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +11 -9
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
- 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/section-edit-form.render.test.tsx +162 -0
- package/src/__tests__/views/section-inspector-media.test.ts +1 -1
- package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
- package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
- package/src/components/CanvasEditTip.tsx +3 -3
- 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 +182 -65
- package/src/views/page-editor/ReviewBar.tsx +76 -0
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +9 -553
- package/src/views/page-editor/SectionsPanel.tsx +72 -16
- package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +18 -26
|
@@ -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
|
+
})
|
|
@@ -109,3 +109,45 @@ describe('EditorTopBar view-on-site vs view-live', () => {
|
|
|
109
109
|
expect(screen.queryByRole('button', { name: /View (live|draft)/ })).toBeNull()
|
|
110
110
|
})
|
|
111
111
|
})
|
|
112
|
+
|
|
113
|
+
describe('EditorTopBar history button', () => {
|
|
114
|
+
it('reports whether history is showing, since it takes over the rail rather than opening a dialog', () => {
|
|
115
|
+
const { unmount } = render(<EditorTopBar {...baseProps} onHistory={vi.fn()} />)
|
|
116
|
+
expect(
|
|
117
|
+
screen.getByRole('button', { name: 'Version history' }).getAttribute('aria-pressed'),
|
|
118
|
+
).toBe('false')
|
|
119
|
+
unmount()
|
|
120
|
+
|
|
121
|
+
render(<EditorTopBar {...baseProps} onHistory={vi.fn()} historyOpen />)
|
|
122
|
+
const open = screen.getByRole('button', { name: 'Version history' })
|
|
123
|
+
expect(open.getAttribute('aria-pressed')).toBe('true')
|
|
124
|
+
expect(open.getAttribute('title')).toBe('Close version history')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('gives every icon-only control a touch target', () => {
|
|
128
|
+
// These buttons are 32px square, which is a fine mouse target and an
|
|
129
|
+
// unusable thumb one. `prenta-touch-target` is what grows them under a
|
|
130
|
+
// coarse pointer — and it is applied per control, so a button written with
|
|
131
|
+
// a conditional className is easy to leave out. Exactly that happened to
|
|
132
|
+
// the history button, whose pressed state made it the odd one out.
|
|
133
|
+
render(
|
|
134
|
+
<EditorTopBar
|
|
135
|
+
{...baseProps}
|
|
136
|
+
onHistory={vi.fn()}
|
|
137
|
+
onViewOnSite={vi.fn()}
|
|
138
|
+
onToggleSitePreview={vi.fn()}
|
|
139
|
+
/>,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
for (const name of [
|
|
143
|
+
'Page settings',
|
|
144
|
+
'Version history',
|
|
145
|
+
'Preview page',
|
|
146
|
+
'Inline site preview',
|
|
147
|
+
'View draft on site',
|
|
148
|
+
]) {
|
|
149
|
+
const button = screen.getByRole('button', { name })
|
|
150
|
+
expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
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 { SectionEditForm } from '../../views/page-editor/SectionEditForm.js'
|
|
5
|
+
import { SectionsList } from '../../views/page-editor/SectionsPanel.js'
|
|
6
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
function section(over: Partial<PageSection> = {}): PageSection {
|
|
11
|
+
return {
|
|
12
|
+
id: 's1',
|
|
13
|
+
sectionType: 'hero',
|
|
14
|
+
name: 'Hero Banner',
|
|
15
|
+
visible: true,
|
|
16
|
+
content: { heading: 'Existing headline' },
|
|
17
|
+
settings: { backgroundType: 'solid', backgroundColor: 'purple', paddingY: 'lg' },
|
|
18
|
+
...over,
|
|
19
|
+
} as PageSection
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderForm(over: Partial<Parameters<typeof SectionEditForm>[0]> = {}) {
|
|
23
|
+
const onContentChange = vi.fn()
|
|
24
|
+
const onMetaChange = vi.fn()
|
|
25
|
+
render(
|
|
26
|
+
<SectionEditForm
|
|
27
|
+
section={section()}
|
|
28
|
+
canEdit
|
|
29
|
+
onContentChange={onContentChange}
|
|
30
|
+
onMetaChange={onMetaChange}
|
|
31
|
+
{...over}
|
|
32
|
+
/>,
|
|
33
|
+
)
|
|
34
|
+
return { onContentChange, onMetaChange }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('SectionEditForm — content only', () => {
|
|
38
|
+
it('renders the type’s content fields', () => {
|
|
39
|
+
renderForm()
|
|
40
|
+
expect(screen.getByDisplayValue('Existing headline')).toBeTruthy()
|
|
41
|
+
// Exact: the hero type also declares "Primary button URL", and a loose
|
|
42
|
+
// match would find both and fail for the wrong reason.
|
|
43
|
+
expect(screen.getByLabelText('Primary button')).toBeTruthy()
|
|
44
|
+
expect(screen.getByLabelText('Primary button URL')).toBeTruthy()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('edits content through the patch sink', () => {
|
|
48
|
+
const { onContentChange } = renderForm()
|
|
49
|
+
fireEvent.change(screen.getByDisplayValue('Existing headline'), {
|
|
50
|
+
target: { value: 'New headline' },
|
|
51
|
+
})
|
|
52
|
+
expect(onContentChange).toHaveBeenCalledWith({ heading: 'New headline' })
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The reason this component exists rather than a trimmed inspector. This CMS
|
|
57
|
+
* edits words, images and embed code; `docs/design-owned-sections.md` says
|
|
58
|
+
* the editor never changes styling, and the old panel offered background,
|
|
59
|
+
* colour, spacing and a CSS class one panel away from that sentence.
|
|
60
|
+
*/
|
|
61
|
+
it('offers no styling controls at all', () => {
|
|
62
|
+
renderForm()
|
|
63
|
+
for (const label of [/^Background$/i, /^Color$/i, /^Spacing$/i, /CSS class/i]) {
|
|
64
|
+
expect(screen.queryByLabelText(label), `${label} is still editable`).toBeNull()
|
|
65
|
+
}
|
|
66
|
+
expect(screen.queryByText(/Appearance/i)).toBeNull()
|
|
67
|
+
expect(screen.queryByRole('combobox')).toBeNull()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('says where the missing controls went, so their absence reads as deliberate', () => {
|
|
71
|
+
renderForm()
|
|
72
|
+
expect(screen.getByText(/come from the template/i)).toBeTruthy()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps the naming fields, which are not styling', () => {
|
|
76
|
+
const { onMetaChange } = renderForm()
|
|
77
|
+
fireEvent.change(screen.getByLabelText('Section name'), { target: { value: 'Intro' } })
|
|
78
|
+
expect(onMetaChange).toHaveBeenCalledWith({ name: 'Intro' })
|
|
79
|
+
expect(screen.getByLabelText('Internal label')).toBeTruthy()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('explains an unmanaged section instead of rendering an empty form', () => {
|
|
83
|
+
renderForm({ section: section({ sectionType: 'opal-pricing', unmanaged: true }) })
|
|
84
|
+
expect(screen.getByText(/Managed outside the editor/i)).toBeTruthy()
|
|
85
|
+
expect(screen.queryByLabelText('Section name')).toBeNull()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('disables every control for a read-only user', () => {
|
|
89
|
+
renderForm({ canEdit: false })
|
|
90
|
+
// Asserted on the fieldset, not the input: a disabled ancestor fieldset is
|
|
91
|
+
// what actually disables the controls, and `input.disabled` reports only
|
|
92
|
+
// the element's own attribute, so checking the input passes either way.
|
|
93
|
+
const field = screen.getByDisplayValue('Existing headline')
|
|
94
|
+
const fieldset = field.closest('fieldset')
|
|
95
|
+
expect(fieldset).toBeTruthy()
|
|
96
|
+
expect(fieldset!.disabled).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
describe('SectionsList — the editor opens inside its row', () => {
|
|
101
|
+
const sections = [section(), section({ id: 's2', name: 'CTA Banner', sectionType: 'cta' })]
|
|
102
|
+
|
|
103
|
+
function renderList(selectedId: string | null, withEditor = true) {
|
|
104
|
+
const onSelect = vi.fn()
|
|
105
|
+
render(
|
|
106
|
+
<SectionsList
|
|
107
|
+
sections={sections}
|
|
108
|
+
selectedId={selectedId}
|
|
109
|
+
canEdit
|
|
110
|
+
onSelect={onSelect}
|
|
111
|
+
onToggleVisibility={vi.fn()}
|
|
112
|
+
renderEditor={
|
|
113
|
+
withEditor
|
|
114
|
+
? (s) => <div data-testid={`editor-${s.id}`}>editor for {s.name}</div>
|
|
115
|
+
: undefined
|
|
116
|
+
}
|
|
117
|
+
/>,
|
|
118
|
+
)
|
|
119
|
+
return { onSelect }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
it('renders the editor for the selected row and no other', () => {
|
|
123
|
+
renderList('s1')
|
|
124
|
+
expect(screen.getByTestId('editor-s1')).toBeTruthy()
|
|
125
|
+
expect(screen.queryByTestId('editor-s2')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders no editor when nothing is selected', () => {
|
|
129
|
+
renderList(null)
|
|
130
|
+
expect(screen.queryByTestId('editor-s1')).toBeNull()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('gives the row a disclosure control that reports its state', () => {
|
|
134
|
+
renderList('s1')
|
|
135
|
+
// The chevron is what says the row opens at all — without one the list
|
|
136
|
+
// reads as links to somewhere else, which is what it was.
|
|
137
|
+
const open = screen.getByRole('button', { name: 'Close Hero Banner' })
|
|
138
|
+
expect(open.getAttribute('aria-expanded')).toBe('true')
|
|
139
|
+
expect(
|
|
140
|
+
screen.getByRole('button', { name: 'Edit CTA Banner' }).getAttribute('aria-expanded'),
|
|
141
|
+
).toBe('false')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('points the disclosure at the editor it opens', () => {
|
|
145
|
+
renderList('s1')
|
|
146
|
+
const control = screen.getByRole('button', { name: 'Close Hero Banner' })
|
|
147
|
+
const target = control.getAttribute('aria-controls')
|
|
148
|
+
expect(target).toBe('section-editor-s1')
|
|
149
|
+
expect(document.getElementById(target!)).toBeTruthy()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('selects a closed row when its chevron is used', () => {
|
|
153
|
+
const { onSelect } = renderList('s1')
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit CTA Banner' }))
|
|
155
|
+
expect(onSelect).toHaveBeenCalledWith('s2')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('shows no disclosure when the list renders no editor (template list)', () => {
|
|
159
|
+
renderList('s1', false)
|
|
160
|
+
expect(screen.queryByRole('button', { name: /^(Edit|Close) / })).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { companionDimensionKeys } from '../../views/page-editor/
|
|
2
|
+
import { companionDimensionKeys } from '../../views/page-editor/section-fields.js'
|
|
3
3
|
|
|
4
4
|
describe('companionDimensionKeys', () => {
|
|
5
5
|
it('maps a *Url field to *Width / *Height (feature, hero)', () => {
|
|
@@ -3,7 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { render, screen } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
import { createSection } from '../../lib/page-editor-service.js'
|
|
6
|
-
import { SectionInspector
|
|
6
|
+
import { SectionInspector } from '../../views/page-editor/SectionInspector.js'
|
|
7
|
+
import { repeaterRowSummary } from '../../views/page-editor/section-fields.js'
|
|
7
8
|
import type { SectionFieldDef } from '../../views/page-editor/section-types.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
|
+
import { VersionHistoryPanel } from '../../views/page-editor/VersionHistoryPanel.js'
|
|
5
|
+
import type { PageVersion } from '../../lib/version-history.js'
|
|
6
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
7
|
+
|
|
8
|
+
afterEach(cleanup)
|
|
9
|
+
|
|
10
|
+
function section(id: string, content: Record<string, unknown>): PageSection {
|
|
11
|
+
return { id, sectionType: 'hero', name: 'Hero Banner', visible: true, content, settings: {} }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NOW = new Date()
|
|
15
|
+
const iso = (hoursAgo: number) => new Date(NOW.getTime() - hoursAgo * 3_600_000).toISOString()
|
|
16
|
+
|
|
17
|
+
const VERSIONS: PageVersion[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'v3',
|
|
20
|
+
changeType: 'PUBLISH',
|
|
21
|
+
createdAt: iso(1),
|
|
22
|
+
author: { id: 'u1', name: 'Sarah Mills' },
|
|
23
|
+
sections: [section('s1', { heading: 'Third headline' })],
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'v2',
|
|
27
|
+
changeType: 'UPDATE',
|
|
28
|
+
createdAt: iso(2),
|
|
29
|
+
author: { id: 'u2', name: 'Dan Ito' },
|
|
30
|
+
sections: [section('s1', { heading: 'Second headline' })],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: 'v1',
|
|
34
|
+
changeType: 'CREATE',
|
|
35
|
+
createdAt: iso(30),
|
|
36
|
+
author: { id: 'u1', name: 'Sarah Mills' },
|
|
37
|
+
sections: [section('s1', { heading: 'First headline' })],
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
function renderPanel(props: Partial<React.ComponentProps<typeof VersionHistoryPanel>> = {}) {
|
|
42
|
+
const onSelect = vi.fn()
|
|
43
|
+
const onRestore = vi.fn()
|
|
44
|
+
const onClose = vi.fn()
|
|
45
|
+
render(
|
|
46
|
+
<VersionHistoryPanel
|
|
47
|
+
versions={VERSIONS}
|
|
48
|
+
loading={false}
|
|
49
|
+
error={null}
|
|
50
|
+
selectedIndex={0}
|
|
51
|
+
onSelect={onSelect}
|
|
52
|
+
onClose={onClose}
|
|
53
|
+
onRestore={onRestore}
|
|
54
|
+
restoring={false}
|
|
55
|
+
canRestore
|
|
56
|
+
{...props}
|
|
57
|
+
/>,
|
|
58
|
+
)
|
|
59
|
+
return { onSelect, onRestore, onClose }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('VersionHistoryPanel', () => {
|
|
63
|
+
it('groups entries under sticky day headings', () => {
|
|
64
|
+
renderPanel()
|
|
65
|
+
expect(screen.getByRole('heading', { name: 'Today' })).toBeTruthy()
|
|
66
|
+
// v1 is 30 hours back, so it lands on its own day.
|
|
67
|
+
expect(screen.getAllByRole('heading', { level: 3 }).length).toBeGreaterThan(1)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('marks the newest entry as current and shows the published pill', () => {
|
|
71
|
+
renderPanel()
|
|
72
|
+
expect(screen.getByText('Current')).toBeTruthy()
|
|
73
|
+
expect(screen.getByText('Published')).toBeTruthy()
|
|
74
|
+
// No pill claims "Draft" — nothing in this codebase records one.
|
|
75
|
+
expect(screen.queryByText('Draft')).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('summarises each entry by what that save changed', () => {
|
|
79
|
+
renderPanel()
|
|
80
|
+
// v3 changed one field against v2.
|
|
81
|
+
expect(screen.getByText(/Sarah Mills · 1 change$/)).toBeTruthy()
|
|
82
|
+
// v1 has no predecessor in the list, so it reports no computed changes
|
|
83
|
+
// rather than claiming it created everything.
|
|
84
|
+
expect(screen.getByText(/Sarah Mills · no content changes/)).toBeTruthy()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('shows the computed field diff, old struck through and new after it', () => {
|
|
88
|
+
renderPanel()
|
|
89
|
+
// The newest changed entry auto-expands.
|
|
90
|
+
expect(screen.getByText('Heading')).toBeTruthy()
|
|
91
|
+
const oldValue = screen.getByText('Second headline')
|
|
92
|
+
const newValue = screen.getByText('Third headline')
|
|
93
|
+
expect(oldValue.className).toContain('line-through')
|
|
94
|
+
expect(newValue.className).not.toContain('line-through')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('selects the version when its row is activated, which is what drives the canvas', () => {
|
|
98
|
+
const { onSelect } = renderPanel()
|
|
99
|
+
fireEvent.click(screen.getByText(/Dan Ito/))
|
|
100
|
+
expect(onSelect).toHaveBeenCalledWith(1)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('offers Restore only on the selected older row', () => {
|
|
104
|
+
renderPanel({ selectedIndex: 0 })
|
|
105
|
+
// Selected row is the current one — restoring it is a no-op.
|
|
106
|
+
expect(screen.queryByRole('button', { name: /Restore this version/ })).toBeNull()
|
|
107
|
+
|
|
108
|
+
cleanup()
|
|
109
|
+
renderPanel({ selectedIndex: 1 })
|
|
110
|
+
const restore = screen.getByRole('button', { name: /Restore this version/ })
|
|
111
|
+
expect(restore).toBeTruthy()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('shows the diff for whichever version is selected, including one selected from outside', () => {
|
|
115
|
+
// The review bar and the canvas both move the selection without the panel
|
|
116
|
+
// being clicked. When disclosure was tracked separately from selection,
|
|
117
|
+
// the diff described one version while the canvas rendered another — and
|
|
118
|
+
// Restore sat on a row that was not open.
|
|
119
|
+
renderPanel({ selectedIndex: 1 })
|
|
120
|
+
expect(screen.getByText('First headline')).toBeTruthy()
|
|
121
|
+
expect(screen.getByText('Second headline')).toBeTruthy()
|
|
122
|
+
expect(screen.queryByText('Third headline')).toBeNull()
|
|
123
|
+
expect(screen.getByRole('button', { name: /Restore this version/ })).toBeTruthy()
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('collapses the row it is already on rather than reselecting it', () => {
|
|
127
|
+
const { onSelect } = renderPanel({ selectedIndex: 0 })
|
|
128
|
+
expect(screen.getByText('Heading')).toBeTruthy()
|
|
129
|
+
|
|
130
|
+
fireEvent.click(screen.getByText(/Sarah Mills · 1 change/))
|
|
131
|
+
|
|
132
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
133
|
+
expect(screen.queryByText('Heading')).toBeNull()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('can restore the oldest version, which by definition has no diff to show', () => {
|
|
137
|
+
// Restore used to live inside the diff block, so a version with no
|
|
138
|
+
// computed changes — the oldest one held, or a metadata-only save — was
|
|
139
|
+
// reviewable and not restorable. That is the wrong way round: the oldest
|
|
140
|
+
// version is the one a rollback most often wants.
|
|
141
|
+
renderPanel({ selectedIndex: 2 })
|
|
142
|
+
|
|
143
|
+
expect(screen.getByText(/No section changes to show/)).toBeTruthy()
|
|
144
|
+
expect(screen.getByRole('button', { name: /Restore this version/ })).toBeTruthy()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('hides Restore from a user who cannot edit', () => {
|
|
148
|
+
renderPanel({ selectedIndex: 1, canRestore: false })
|
|
149
|
+
expect(screen.queryByRole('button', { name: /Restore this version/ })).toBeNull()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('restores the version whose row was clicked', () => {
|
|
153
|
+
const { onRestore } = renderPanel({ selectedIndex: 1 })
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: /Restore this version/ }))
|
|
155
|
+
expect(onRestore).toHaveBeenCalledWith(expect.objectContaining({ id: 'v2' }))
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('renders two non-adjacent groups sharing a day label without colliding', () => {
|
|
159
|
+
// `groupVersionsByDay` walks CONSECUTIVE runs, so an out-of-order page
|
|
160
|
+
// legitimately yields ['Today', 'Yesterday', 'Today'] — its own test
|
|
161
|
+
// asserts that. Keyed on the label, the second "Today" collides with the
|
|
162
|
+
// first and React mis-associates the two sections' contents.
|
|
163
|
+
const outOfOrder: PageVersion[] = [
|
|
164
|
+
{ ...VERSIONS[0]!, id: 'a', createdAt: iso(1) },
|
|
165
|
+
{ ...VERSIONS[1]!, id: 'b', createdAt: iso(30) },
|
|
166
|
+
{ ...VERSIONS[2]!, id: 'c', createdAt: iso(2) },
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
170
|
+
renderPanel({ versions: outOfOrder })
|
|
171
|
+
|
|
172
|
+
expect(screen.getAllByRole('heading', { name: 'Today' })).toHaveLength(2)
|
|
173
|
+
expect(
|
|
174
|
+
spy.mock.calls.some((call) => String(call[0]).includes('same key')),
|
|
175
|
+
'React reported duplicate keys',
|
|
176
|
+
).toBe(false)
|
|
177
|
+
spy.mockRestore()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('reports an empty history rather than an empty box', () => {
|
|
181
|
+
renderPanel({ versions: [] })
|
|
182
|
+
expect(screen.getByText(/No versions yet/)).toBeTruthy()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('surfaces a load failure', () => {
|
|
186
|
+
renderPanel({ versions: [], error: 'Network unreachable' })
|
|
187
|
+
expect(screen.getByText('Network unreachable')).toBeTruthy()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('announces itself as a named region so it is reachable without sight of the overlay', () => {
|
|
191
|
+
renderPanel()
|
|
192
|
+
const region = screen.getByRole('region', { name: 'Version history' })
|
|
193
|
+
expect(within(region).getByRole('button', { name: 'Close version history' })).toBeTruthy()
|
|
194
|
+
})
|
|
195
|
+
})
|