@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.
Files changed (130) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.js +37 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -1
  4. package/dist/__tests__/lib/version-diff.test.d.ts +2 -0
  5. package/dist/__tests__/lib/version-diff.test.d.ts.map +1 -0
  6. package/dist/__tests__/lib/version-diff.test.js +230 -0
  7. package/dist/__tests__/lib/version-diff.test.js.map +1 -0
  8. package/dist/__tests__/lib/version-history.test.d.ts +2 -0
  9. package/dist/__tests__/lib/version-history.test.d.ts.map +1 -0
  10. package/dist/__tests__/lib/version-history.test.js +125 -0
  11. package/dist/__tests__/lib/version-history.test.js.map +1 -0
  12. package/dist/__tests__/styles/responsive-layer.test.d.ts +2 -0
  13. package/dist/__tests__/styles/responsive-layer.test.d.ts.map +1 -0
  14. package/dist/__tests__/styles/responsive-layer.test.js +127 -0
  15. package/dist/__tests__/styles/responsive-layer.test.js.map +1 -0
  16. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/canvas-review-mode.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/canvas-review-mode.render.test.js +70 -0
  19. package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -0
  20. package/dist/__tests__/views/editor-top-bar.render.test.js +29 -0
  21. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  22. package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
  23. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  24. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  25. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  26. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  27. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  28. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  29. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  30. package/dist/__tests__/views/version-history-panel.render.test.d.ts +2 -0
  31. package/dist/__tests__/views/version-history-panel.render.test.d.ts.map +1 -0
  32. package/dist/__tests__/views/version-history-panel.render.test.js +153 -0
  33. package/dist/__tests__/views/version-history-panel.render.test.js.map +1 -0
  34. package/dist/components/CanvasEditTip.js +1 -1
  35. package/dist/components/CanvasEditTip.js.map +1 -1
  36. package/dist/components/EditorRail.js +3 -3
  37. package/dist/components/EditorRail.js.map +1 -1
  38. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -1
  39. package/dist/components/page-tab/DirectiveSwitch.js +5 -1
  40. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -1
  41. package/dist/layout/primitives/AdminShell.d.ts.map +1 -1
  42. package/dist/layout/primitives/AdminShell.js +2 -2
  43. package/dist/layout/primitives/AdminShell.js.map +1 -1
  44. package/dist/lib/version-diff.d.ts +50 -0
  45. package/dist/lib/version-diff.d.ts.map +1 -0
  46. package/dist/lib/version-diff.js +294 -0
  47. package/dist/lib/version-diff.js.map +1 -0
  48. package/dist/lib/version-history.d.ts +73 -0
  49. package/dist/lib/version-history.d.ts.map +1 -0
  50. package/dist/lib/version-history.js +126 -0
  51. package/dist/lib/version-history.js.map +1 -0
  52. package/dist/prenta-admin.css +1 -1
  53. package/dist/views/page-editor/EditorCanvas.d.ts +14 -1
  54. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  55. package/dist/views/page-editor/EditorCanvas.js +3 -3
  56. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  57. package/dist/views/page-editor/EditorTopBar.d.ts +8 -2
  58. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  59. package/dist/views/page-editor/EditorTopBar.js +8 -6
  60. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  61. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  62. package/dist/views/page-editor/PageSectionEditor.js +129 -40
  63. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  64. package/dist/views/page-editor/ReviewBar.d.ts +21 -0
  65. package/dist/views/page-editor/ReviewBar.d.ts.map +1 -0
  66. package/dist/views/page-editor/ReviewBar.js +22 -0
  67. package/dist/views/page-editor/ReviewBar.js.map +1 -0
  68. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  69. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  70. package/dist/views/page-editor/SectionEditForm.js +48 -0
  71. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  72. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  73. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  74. package/dist/views/page-editor/SectionInspector.js +6 -224
  75. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  76. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  77. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  78. package/dist/views/page-editor/SectionsPanel.js +11 -5
  79. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  80. package/dist/views/page-editor/VersionHistoryPanel.d.ts +29 -0
  81. package/dist/views/page-editor/VersionHistoryPanel.d.ts.map +1 -0
  82. package/dist/views/page-editor/VersionHistoryPanel.js +81 -0
  83. package/dist/views/page-editor/VersionHistoryPanel.js.map +1 -0
  84. package/dist/views/page-editor/section-fields.d.ts +43 -0
  85. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  86. package/dist/views/page-editor/section-fields.js +236 -0
  87. package/dist/views/page-editor/section-fields.js.map +1 -0
  88. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  89. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  90. package/dist/views/page-editor/sections/SectionRenderer.d.ts +13 -1
  91. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  92. package/dist/views/page-editor/sections/SectionRenderer.js +23 -8
  93. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  94. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  95. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  96. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  97. package/dist/views/post-editor/PostSectionEditor.js +11 -9
  98. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  99. package/package.json +3 -3
  100. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  101. package/src/__tests__/lib/version-diff.test.ts +278 -0
  102. package/src/__tests__/lib/version-history.test.ts +166 -0
  103. package/src/__tests__/styles/responsive-layer.test.ts +152 -0
  104. package/src/__tests__/views/canvas-review-mode.render.test.tsx +94 -0
  105. package/src/__tests__/views/editor-top-bar.render.test.tsx +42 -0
  106. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  107. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  108. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  109. package/src/__tests__/views/version-history-panel.render.test.tsx +195 -0
  110. package/src/components/CanvasEditTip.tsx +3 -3
  111. package/src/components/EditorRail.tsx +5 -5
  112. package/src/components/page-tab/DirectiveSwitch.tsx +4 -1
  113. package/src/layout/primitives/AdminShell.tsx +13 -1
  114. package/src/lib/version-diff.ts +376 -0
  115. package/src/lib/version-history.ts +192 -0
  116. package/src/styles/responsive.css +281 -0
  117. package/src/styles/theme.css +8 -0
  118. package/src/views/page-editor/EditorCanvas.tsx +34 -7
  119. package/src/views/page-editor/EditorTopBar.tsx +20 -8
  120. package/src/views/page-editor/PageSectionEditor.tsx +182 -65
  121. package/src/views/page-editor/ReviewBar.tsx +76 -0
  122. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  123. package/src/views/page-editor/SectionInspector.tsx +9 -553
  124. package/src/views/page-editor/SectionsPanel.tsx +72 -16
  125. package/src/views/page-editor/VersionHistoryPanel.tsx +302 -0
  126. package/src/views/page-editor/section-fields.tsx +563 -0
  127. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  128. package/src/views/page-editor/sections/SectionRenderer.tsx +53 -12
  129. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  130. 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/SectionInspector.js'
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, repeaterRowSummary } from '../../views/page-editor/SectionInspector.js'
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
+ })