@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +55 -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__/views/page-section-editor.test.js +39 -1
  5. package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
  6. package/dist/__tests__/views/post-section-editor.test.js +15 -1
  7. package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
  8. package/dist/__tests__/views/section-edit-form.render.test.d.ts +2 -0
  9. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  11. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  12. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  13. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  14. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  15. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  16. package/dist/components/CanvasEditTip.js +1 -1
  17. package/dist/components/CanvasEditTip.js.map +1 -1
  18. package/dist/prenta-admin.css +1 -1
  19. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  20. package/dist/views/page-editor/PageSectionEditor.js +30 -11
  21. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  22. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  23. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  24. package/dist/views/page-editor/SectionEditForm.js +48 -0
  25. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  26. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  27. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  28. package/dist/views/page-editor/SectionInspector.js +5 -223
  29. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  30. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  31. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  32. package/dist/views/page-editor/SectionsPanel.js +11 -5
  33. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  34. package/dist/views/page-editor/section-fields.d.ts +43 -0
  35. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  36. package/dist/views/page-editor/section-fields.js +236 -0
  37. package/dist/views/page-editor/section-fields.js.map +1 -0
  38. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  39. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  40. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  41. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  42. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  43. package/dist/views/post-editor/PostSectionEditor.js +15 -10
  44. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  45. package/package.json +3 -3
  46. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  47. package/src/__tests__/views/page-section-editor.test.tsx +58 -1
  48. package/src/__tests__/views/post-section-editor.test.tsx +29 -1
  49. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  50. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  51. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  52. package/src/components/CanvasEditTip.tsx +3 -3
  53. package/src/views/page-editor/PageSectionEditor.tsx +35 -25
  54. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  55. package/src/views/page-editor/SectionInspector.tsx +7 -552
  56. package/src/views/page-editor/SectionsPanel.tsx +70 -14
  57. package/src/views/page-editor/section-fields.tsx +563 -0
  58. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  59. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  60. 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/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
  /**
@@ -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
- Hover sections for a light outline, then click to select. Edit copy and images in the
30
- inspector. Use <span className="text-foreground font-medium">Site preview</span> to click
31
- text and images in place.
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 { SectionInspector } from './SectionInspector.js'
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
- const handleSettingsChange = useCallback(
512
- (patch: Partial<SectionSettings>) => {
513
- if (!selectedId) return
514
- mutateSections((s) => updateSectionSettings(s, selectedId, patch))
515
- },
516
- [selectedId, mutateSections],
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&rsquo;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
+ }