@prenta/admin 0.94.0 → 0.95.1
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 +55 -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__/views/page-section-editor.test.js +39 -1
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +15 -1
- package/dist/__tests__/views/post-section-editor.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/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/prenta-admin.css +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +30 -11
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- 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 +5 -223
- 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/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/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 +15 -10
- 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__/views/page-section-editor.test.tsx +58 -1
- package/src/__tests__/views/post-section-editor.test.tsx +29 -1
- 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/components/CanvasEditTip.tsx +3 -3
- package/src/views/page-editor/PageSectionEditor.tsx +35 -25
- package/src/views/page-editor/SectionEditForm.tsx +161 -0
- package/src/views/page-editor/SectionInspector.tsx +7 -552
- package/src/views/page-editor/SectionsPanel.tsx +70 -14
- package/src/views/page-editor/section-fields.tsx +563 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
- package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
- package/src/views/post-editor/PostSectionEditor.tsx +22 -27
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
const fetchPageForEditor = vi.fn()
|
|
6
6
|
vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
|
|
@@ -177,3 +177,60 @@ describe('PageSectionEditor structure mode', () => {
|
|
|
177
177
|
expect(screen.getByRole('button', { name: 'Version history' })).toBeTruthy()
|
|
178
178
|
})
|
|
179
179
|
})
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Selection IS disclosure now that the editor opens inside the row, so
|
|
183
|
+
* re-picking the open section has to close it. Before the toggle, the chevron
|
|
184
|
+
* and the row both re-selected what was already selected and nothing happened
|
|
185
|
+
* — the rail had no way to close an open accordion at all.
|
|
186
|
+
*/
|
|
187
|
+
describe('PageSectionEditor section disclosure', () => {
|
|
188
|
+
it('opens a section, and closes it when picked again', async () => {
|
|
189
|
+
fetchPageForEditor.mockResolvedValue(
|
|
190
|
+
loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
|
|
191
|
+
)
|
|
192
|
+
render(
|
|
193
|
+
<PageSectionEditor documentId="p1" config={validConfig} onNavigate={() => {}} session={{}} />,
|
|
194
|
+
)
|
|
195
|
+
await screen.findByText('Sections')
|
|
196
|
+
|
|
197
|
+
const open = () => screen.getByRole('button', { name: 'Edit Hero' })
|
|
198
|
+
const close = () => screen.queryByRole('button', { name: 'Close Hero' })
|
|
199
|
+
|
|
200
|
+
expect(close()).toBeNull()
|
|
201
|
+
|
|
202
|
+
fireEvent.click(open())
|
|
203
|
+
await screen.findByRole('button', { name: 'Close Hero' })
|
|
204
|
+
expect(screen.getByLabelText('Section name')).toBeTruthy()
|
|
205
|
+
|
|
206
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close Hero' }))
|
|
207
|
+
await waitFor(() => expect(close()).toBeNull())
|
|
208
|
+
expect(screen.queryByLabelText('Section name')).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('keeps the row open when the site preview points at one of its fields', async () => {
|
|
212
|
+
// A `fieldPath` means "the editor clicked THIS field" — toggling there
|
|
213
|
+
// would close the form on the second click of a field being edited.
|
|
214
|
+
fetchPageForEditor.mockResolvedValue(
|
|
215
|
+
loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
|
|
216
|
+
)
|
|
217
|
+
render(
|
|
218
|
+
<PageSectionEditor documentId="p1" config={validConfig} onNavigate={() => {}} session={{}} />,
|
|
219
|
+
)
|
|
220
|
+
await screen.findByText('Sections')
|
|
221
|
+
|
|
222
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
|
|
223
|
+
await screen.findByRole('button', { name: 'Close Hero' })
|
|
224
|
+
|
|
225
|
+
// Same section, arriving with a field path, twice.
|
|
226
|
+
for (let i = 0; i < 2; i++) {
|
|
227
|
+
window.dispatchEvent(
|
|
228
|
+
new MessageEvent('message', {
|
|
229
|
+
data: { v: 1, type: 'prenta-preview:select', sectionId: 's1', fieldPath: 'heading' },
|
|
230
|
+
}),
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
|
|
235
|
+
})
|
|
236
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
const fetchPostForEditor = vi.fn()
|
|
6
6
|
const fetchTemplateForEditor = vi.fn()
|
|
@@ -171,3 +171,31 @@ describe('PostSectionEditor unmanaged sections', () => {
|
|
|
171
171
|
expect(screen.queryByText(/managed outside the editor/)).toBeNull()
|
|
172
172
|
})
|
|
173
173
|
})
|
|
174
|
+
|
|
175
|
+
/** Same disclosure contract as the page editor — see the note on its copy. */
|
|
176
|
+
describe('PostSectionEditor section disclosure', () => {
|
|
177
|
+
it('opens a section, and closes it when picked again', async () => {
|
|
178
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
179
|
+
fetchPostForEditor.mockResolvedValue(
|
|
180
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
181
|
+
)
|
|
182
|
+
render(
|
|
183
|
+
<PostSectionEditor
|
|
184
|
+
postType="blog"
|
|
185
|
+
documentId="p1"
|
|
186
|
+
config={validConfig}
|
|
187
|
+
onNavigate={() => {}}
|
|
188
|
+
session={{}}
|
|
189
|
+
/>,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
const open = await screen.findByRole('button', { name: 'Edit Quote' })
|
|
193
|
+
expect(screen.queryByRole('button', { name: 'Close Quote' })).toBeNull()
|
|
194
|
+
|
|
195
|
+
fireEvent.click(open)
|
|
196
|
+
await screen.findByRole('button', { name: 'Close Quote' })
|
|
197
|
+
|
|
198
|
+
fireEvent.click(screen.getByRole('button', { name: 'Close Quote' }))
|
|
199
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Close Quote' })).toBeNull())
|
|
200
|
+
})
|
|
201
|
+
})
|
|
@@ -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
|
/**
|
|
@@ -26,9 +26,9 @@ export function CanvasEditTip() {
|
|
|
26
26
|
<p className="min-w-0 flex-1 leading-[1.5]">
|
|
27
27
|
<span className="text-foreground font-medium">Editing tip</span>
|
|
28
28
|
{' — '}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
Click a section on the canvas, or in the list, to open its copy and images in the section
|
|
30
|
+
list. Use <span className="text-foreground font-medium">Site preview</span> to click text
|
|
31
|
+
and images in place.
|
|
32
32
|
</p>
|
|
33
33
|
<button
|
|
34
34
|
type="button"
|
|
@@ -29,7 +29,6 @@ import {
|
|
|
29
29
|
updateSectionContent,
|
|
30
30
|
updateSectionContentAtPath,
|
|
31
31
|
updateSectionMeta,
|
|
32
|
-
updateSectionSettings,
|
|
33
32
|
validatePage,
|
|
34
33
|
type EditorPage,
|
|
35
34
|
type ValidationResult,
|
|
@@ -41,7 +40,6 @@ import {
|
|
|
41
40
|
createPreviewToken,
|
|
42
41
|
} from '../../lib/preview-link.js'
|
|
43
42
|
import { usePublicSiteUrl } from '../../hooks/usePublicSiteUrl.js'
|
|
44
|
-
import type { SectionSettings } from './section-types.js'
|
|
45
43
|
import { DEFAULT_VIEWPORT, type ViewportId } from './viewports.js'
|
|
46
44
|
import type { Zoom } from '../../lib/breakpoints.js'
|
|
47
45
|
import { snapshotBaseline, type ReflowBaseline } from '../../lib/reflow-baseline.js'
|
|
@@ -63,7 +61,7 @@ import { EditorCanvas } from './EditorCanvas.js'
|
|
|
63
61
|
import { ReflowContext } from './reflow-context.js'
|
|
64
62
|
import { SitePreviewFrame } from './SitePreviewFrame.js'
|
|
65
63
|
import { useSitePreview } from './useSitePreview.js'
|
|
66
|
-
import {
|
|
64
|
+
import { SectionEditForm } from './SectionEditForm.js'
|
|
67
65
|
import { PageSettingsModal } from './PageSettingsModal.js'
|
|
68
66
|
import { ValidationSummary } from './ValidationSummary.js'
|
|
69
67
|
import { resolveDefaultSitePreview } from '../../lib/editor-preview.js'
|
|
@@ -508,13 +506,12 @@ export function PageSectionEditor({
|
|
|
508
506
|
[selectedId, mutateSections],
|
|
509
507
|
)
|
|
510
508
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
)
|
|
509
|
+
// NOTE: there is deliberately no `handleSettingsChange` here any more.
|
|
510
|
+
// `settings` holds background, spacing, anchor and CSS class — the template's
|
|
511
|
+
// business, not the editor's (docs/design-owned-sections.md). The values
|
|
512
|
+
// still round-trip through save untouched and the renderers still read them;
|
|
513
|
+
// this editor simply no longer offers to change them. The post TEMPLATE
|
|
514
|
+
// editor keeps the full set, because that is the surface that authors one.
|
|
518
515
|
|
|
519
516
|
const handleMetaChange = useCallback(
|
|
520
517
|
(patch: { name?: string; internalLabel?: string }) => {
|
|
@@ -746,8 +743,20 @@ export function PageSectionEditor({
|
|
|
746
743
|
// Selecting on the canvas is a request to edit that section — show the
|
|
747
744
|
// outline it belongs to, whichever tab was open.
|
|
748
745
|
setRailTab('sections')
|
|
749
|
-
setSelectedId(id)
|
|
750
746
|
setPendingFocusPath(fieldPath ?? null)
|
|
747
|
+
setSelectedId((current) => {
|
|
748
|
+
// Selection IS disclosure now that the editor opens inside the row, so
|
|
749
|
+
// re-picking the open section has to close it. Without the toggle the
|
|
750
|
+
// rail has no close affordance at all — the chevron and the row both
|
|
751
|
+
// re-select what is already selected and nothing happens.
|
|
752
|
+
//
|
|
753
|
+
// Except when a fieldPath came with it: that is the site preview saying
|
|
754
|
+
// "the editor clicked THIS field", which must open and focus it. Making
|
|
755
|
+
// that toggle would close the form on the second click of a field the
|
|
756
|
+
// user is trying to edit.
|
|
757
|
+
if (fieldPath) return id
|
|
758
|
+
return current === id ? null : id
|
|
759
|
+
})
|
|
751
760
|
}, [])
|
|
752
761
|
|
|
753
762
|
// `effectiveCanEdit`, not `canEdit`: the site preview is a second editing
|
|
@@ -902,8 +911,6 @@ export function PageSectionEditor({
|
|
|
902
911
|
)
|
|
903
912
|
}
|
|
904
913
|
|
|
905
|
-
const selected = page.sections.find((s) => s.id === selectedId) ?? null
|
|
906
|
-
|
|
907
914
|
// `readOnly` / `effectiveCanEdit` / `canRestoreVersion` are computed above,
|
|
908
915
|
// with the hooks, so the callbacks defined there share this one gate.
|
|
909
916
|
|
|
@@ -1049,6 +1056,21 @@ export function PageSectionEditor({
|
|
|
1049
1056
|
canEdit={effectiveCanEdit}
|
|
1050
1057
|
onSelect={handleSelectSection}
|
|
1051
1058
|
onToggleVisibility={handleToggleVisibility}
|
|
1059
|
+
// The editor opens inside the row it belongs to. While
|
|
1060
|
+
// reviewing a past version there is nothing to edit, so
|
|
1061
|
+
// rows stay closed rather than opening a dead form.
|
|
1062
|
+
renderEditor={
|
|
1063
|
+
reviewing
|
|
1064
|
+
? undefined
|
|
1065
|
+
: (section) => (
|
|
1066
|
+
<SectionEditForm
|
|
1067
|
+
section={section}
|
|
1068
|
+
canEdit={effectiveCanEdit}
|
|
1069
|
+
onContentChange={handleContentChange}
|
|
1070
|
+
onMetaChange={handleMetaChange}
|
|
1071
|
+
/>
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1052
1074
|
/>
|
|
1053
1075
|
) : railTab === 'page' ? (
|
|
1054
1076
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
|
@@ -1153,18 +1175,6 @@ export function PageSectionEditor({
|
|
|
1153
1175
|
/>
|
|
1154
1176
|
)}
|
|
1155
1177
|
</div>
|
|
1156
|
-
|
|
1157
|
-
{selected && railTab === 'sections' && !reviewing && (
|
|
1158
|
-
<SectionInspector
|
|
1159
|
-
section={selected}
|
|
1160
|
-
canEdit={effectiveCanEdit}
|
|
1161
|
-
onClose={() => setSelectedId(null)}
|
|
1162
|
-
onContentChange={handleContentChange}
|
|
1163
|
-
onSettingsChange={handleSettingsChange}
|
|
1164
|
-
onMetaChange={handleMetaChange}
|
|
1165
|
-
onToggleVisibility={() => handleToggleVisibility(selected.id)}
|
|
1166
|
-
/>
|
|
1167
|
-
)}
|
|
1168
1178
|
</div>
|
|
1169
1179
|
</ReflowContext.Provider>
|
|
1170
1180
|
</div>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Lock } from 'lucide-react'
|
|
4
|
+
import { RichTextField } from '../../fields/RichTextField.js'
|
|
5
|
+
import type { PageSection } from '../../lib/page-editor-service.js'
|
|
6
|
+
import { ContentField, FIELD_INPUT, FIELD_LABEL } from './section-fields.js'
|
|
7
|
+
import { getSectionType } from './section-types.js'
|
|
8
|
+
|
|
9
|
+
export interface SectionEditFormProps {
|
|
10
|
+
section: PageSection
|
|
11
|
+
canEdit: boolean
|
|
12
|
+
onContentChange: (patch: Record<string, unknown>) => void
|
|
13
|
+
onMetaChange: (patch: { name?: string; internalLabel?: string }) => void
|
|
14
|
+
/** Post body field — shown when an Article Body section is bound to the post field. */
|
|
15
|
+
postBody?: string
|
|
16
|
+
onPostBodyChange?: (html: string) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The section editor, rendered inline as the body of its row.
|
|
21
|
+
*
|
|
22
|
+
* ── Why inline, and not a docked panel ────────────────────────────────────
|
|
23
|
+
*
|
|
24
|
+
* The design puts the form inside the row it belongs to: click a section, it
|
|
25
|
+
* opens underneath. It never had a right-hand panel. The panel that existed
|
|
26
|
+
* here was left over from the pre-handoff editor, and the rail work that
|
|
27
|
+
* introduced the tab strip explicitly retired the OTHER right-hand aside — the
|
|
28
|
+
* SEO one — on the grounds that "the editor has one settings surface rather
|
|
29
|
+
* than two competing ones". This is that same second surface, finally closed.
|
|
30
|
+
*
|
|
31
|
+
* ── Why there is no Appearance group ──────────────────────────────────────
|
|
32
|
+
*
|
|
33
|
+
* This CMS edits words, images and embed code. Background, colour and spacing
|
|
34
|
+
* are the template's business, and `docs/design-owned-sections.md` already
|
|
35
|
+
* says so in as many words: the editor changes "copy, CTAs, and imagery —
|
|
36
|
+
* never layout, order, or styling". The controls contradicted that rule while
|
|
37
|
+
* sitting one panel away from the sentence stating it.
|
|
38
|
+
*
|
|
39
|
+
* The VALUES are untouched. `settings` round-trips through save exactly as
|
|
40
|
+
* before, the renderers keep reading it, and every existing page renders
|
|
41
|
+
* identically — the editor simply stops offering to change it. Removing the
|
|
42
|
+
* controls and dropping the data are very different changes, and this is only
|
|
43
|
+
* the first. The template editor keeps the full set: it is the surface that
|
|
44
|
+
* authors a template, and styling genuinely belongs there.
|
|
45
|
+
*/
|
|
46
|
+
export function SectionEditForm({
|
|
47
|
+
section,
|
|
48
|
+
canEdit,
|
|
49
|
+
onContentChange,
|
|
50
|
+
onMetaChange,
|
|
51
|
+
postBody,
|
|
52
|
+
onPostBodyChange,
|
|
53
|
+
}: SectionEditFormProps) {
|
|
54
|
+
const def = getSectionType(section.sectionType)
|
|
55
|
+
|
|
56
|
+
// Unmanaged types have no registered field schema, so there is nothing to
|
|
57
|
+
// render a form from. Say why rather than showing an empty box.
|
|
58
|
+
if (section.unmanaged) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3">
|
|
61
|
+
<p className="text-muted-foreground flex items-start gap-2 text-xs leading-relaxed">
|
|
62
|
+
<Lock className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
63
|
+
<span>
|
|
64
|
+
<span className="text-foreground font-medium">Managed outside the editor</span> — this
|
|
65
|
+
section’s type (<code className="font-mono">{section.sectionType}</code>) is not
|
|
66
|
+
registered, so it is preserved on save but edited in code.
|
|
67
|
+
</span>
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bodySource = String(section.content.source ?? 'field')
|
|
74
|
+
const editPostBodyField =
|
|
75
|
+
section.sectionType === 'article-body' &&
|
|
76
|
+
bodySource === 'field' &&
|
|
77
|
+
onPostBodyChange !== undefined
|
|
78
|
+
const contentFields = def?.fields.filter((f) => !(editPostBodyField && f.key === 'body')) ?? []
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="border-border bg-muted/40 border-t px-3 py-3.5">
|
|
82
|
+
<fieldset disabled={!canEdit} className="space-y-3">
|
|
83
|
+
{editPostBodyField && (
|
|
84
|
+
<RichTextField
|
|
85
|
+
label="Body"
|
|
86
|
+
value={postBody ?? ''}
|
|
87
|
+
onChange={onPostBodyChange}
|
|
88
|
+
className="[&_.ProseMirror]:min-h-[240px]"
|
|
89
|
+
helpText="Main article copy — rendered in the canvas preview and on the live site."
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{contentFields.map((field) => (
|
|
94
|
+
<ContentField
|
|
95
|
+
key={field.key}
|
|
96
|
+
id={`sec-${section.id}-${field.key}`}
|
|
97
|
+
field={field}
|
|
98
|
+
value={section.content[field.key]}
|
|
99
|
+
onChange={(v) => onContentChange({ [field.key]: v })}
|
|
100
|
+
onPatch={(patch) => onContentChange(patch)}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
|
|
104
|
+
{contentFields.length === 0 && !editPostBodyField && (
|
|
105
|
+
<p className="text-muted-foreground text-xs">
|
|
106
|
+
This section has no editable content — it is rendered entirely from the template.
|
|
107
|
+
</p>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
<details className="group">
|
|
111
|
+
<summary className="text-muted-foreground hover:text-foreground cursor-pointer list-none text-[11px] font-medium tracking-wider uppercase">
|
|
112
|
+
Section settings
|
|
113
|
+
</summary>
|
|
114
|
+
<div className="mt-2.5 space-y-3">
|
|
115
|
+
<div>
|
|
116
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-name`}>
|
|
117
|
+
Section name
|
|
118
|
+
</label>
|
|
119
|
+
<input
|
|
120
|
+
id={`sec-${section.id}-name`}
|
|
121
|
+
className={FIELD_INPUT}
|
|
122
|
+
value={section.name}
|
|
123
|
+
onChange={(e) => onMetaChange({ name: e.target.value })}
|
|
124
|
+
/>
|
|
125
|
+
</div>
|
|
126
|
+
<div>
|
|
127
|
+
<label className={FIELD_LABEL} htmlFor={`sec-${section.id}-internal`}>
|
|
128
|
+
Internal label
|
|
129
|
+
</label>
|
|
130
|
+
<input
|
|
131
|
+
id={`sec-${section.id}-internal`}
|
|
132
|
+
className={FIELD_INPUT}
|
|
133
|
+
placeholder="Team-only note (optional)"
|
|
134
|
+
value={section.internalLabel ?? ''}
|
|
135
|
+
onChange={(e) => onMetaChange({ internalLabel: e.target.value })}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
</details>
|
|
140
|
+
</fieldset>
|
|
141
|
+
|
|
142
|
+
{/* The design's locked note, per section rather than once per rail: this
|
|
143
|
+
is where an editor looks for the control that is missing. */}
|
|
144
|
+
<p className="text-muted-foreground mt-3 flex items-start gap-1.5 text-[11px] leading-relaxed">
|
|
145
|
+
<Lock className="mt-0.5 h-3 w-3 shrink-0" aria-hidden />
|
|
146
|
+
<span>
|
|
147
|
+
Background, spacing and layout come from the template.{' '}
|
|
148
|
+
<a
|
|
149
|
+
href="https://github.com/actuate-media/prenta/blob/main/docs/design-owned-sections.md"
|
|
150
|
+
target="_blank"
|
|
151
|
+
rel="noopener noreferrer"
|
|
152
|
+
className="text-brand inline-flex items-center gap-0.5 hover:underline"
|
|
153
|
+
>
|
|
154
|
+
Why
|
|
155
|
+
<ExternalLink className="h-2.5 w-2.5" aria-hidden />
|
|
156
|
+
</a>
|
|
157
|
+
</span>
|
|
158
|
+
</p>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|