@prenta/admin 0.94.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 (54) hide show
  1. package/CHANGELOG.md +41 -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/section-edit-form.render.test.d.ts +2 -0
  5. package/dist/__tests__/views/section-edit-form.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/views/section-edit-form.render.test.js +124 -0
  7. package/dist/__tests__/views/section-edit-form.render.test.js.map +1 -0
  8. package/dist/__tests__/views/section-inspector-media.test.js +1 -1
  9. package/dist/__tests__/views/section-inspector-media.test.js.map +1 -1
  10. package/dist/__tests__/views/section-inspector-repeater.render.test.js +2 -1
  11. package/dist/__tests__/views/section-inspector-repeater.render.test.js.map +1 -1
  12. package/dist/components/CanvasEditTip.js +1 -1
  13. package/dist/components/CanvasEditTip.js.map +1 -1
  14. package/dist/prenta-admin.css +1 -1
  15. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  16. package/dist/views/page-editor/PageSectionEditor.js +16 -10
  17. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  18. package/dist/views/page-editor/SectionEditForm.d.ts +42 -0
  19. package/dist/views/page-editor/SectionEditForm.d.ts.map +1 -0
  20. package/dist/views/page-editor/SectionEditForm.js +48 -0
  21. package/dist/views/page-editor/SectionEditForm.js.map +1 -0
  22. package/dist/views/page-editor/SectionInspector.d.ts +0 -20
  23. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  24. package/dist/views/page-editor/SectionInspector.js +5 -223
  25. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  26. package/dist/views/page-editor/SectionsPanel.d.ts +10 -1
  27. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  28. package/dist/views/page-editor/SectionsPanel.js +11 -5
  29. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  30. package/dist/views/page-editor/section-fields.d.ts +43 -0
  31. package/dist/views/page-editor/section-fields.d.ts.map +1 -0
  32. package/dist/views/page-editor/section-fields.js +236 -0
  33. package/dist/views/page-editor/section-fields.js.map +1 -0
  34. package/dist/views/page-editor/sections/ArticleBodySection.js +1 -1
  35. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -1
  36. package/dist/views/post-editor/PostEditorCanvas.js +1 -1
  37. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  38. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  39. package/dist/views/post-editor/PostSectionEditor.js +11 -9
  40. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/__tests__/lib/page-editor-service-api.test.ts +44 -0
  43. package/src/__tests__/views/section-edit-form.render.test.tsx +162 -0
  44. package/src/__tests__/views/section-inspector-media.test.ts +1 -1
  45. package/src/__tests__/views/section-inspector-repeater.render.test.tsx +2 -1
  46. package/src/components/CanvasEditTip.tsx +3 -3
  47. package/src/views/page-editor/PageSectionEditor.tsx +22 -24
  48. package/src/views/page-editor/SectionEditForm.tsx +161 -0
  49. package/src/views/page-editor/SectionInspector.tsx +7 -552
  50. package/src/views/page-editor/SectionsPanel.tsx +70 -14
  51. package/src/views/page-editor/section-fields.tsx +563 -0
  52. package/src/views/page-editor/sections/ArticleBodySection.tsx +1 -1
  53. package/src/views/post-editor/PostEditorCanvas.tsx +1 -1
  54. package/src/views/post-editor/PostSectionEditor.tsx +18 -26
@@ -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 }) => {
@@ -902,8 +899,6 @@ export function PageSectionEditor({
902
899
  )
903
900
  }
904
901
 
905
- const selected = page.sections.find((s) => s.id === selectedId) ?? null
906
-
907
902
  // `readOnly` / `effectiveCanEdit` / `canRestoreVersion` are computed above,
908
903
  // with the hooks, so the callbacks defined there share this one gate.
909
904
 
@@ -1049,6 +1044,21 @@ export function PageSectionEditor({
1049
1044
  canEdit={effectiveCanEdit}
1050
1045
  onSelect={handleSelectSection}
1051
1046
  onToggleVisibility={handleToggleVisibility}
1047
+ // The editor opens inside the row it belongs to. While
1048
+ // reviewing a past version there is nothing to edit, so
1049
+ // rows stay closed rather than opening a dead form.
1050
+ renderEditor={
1051
+ reviewing
1052
+ ? undefined
1053
+ : (section) => (
1054
+ <SectionEditForm
1055
+ section={section}
1056
+ canEdit={effectiveCanEdit}
1057
+ onContentChange={handleContentChange}
1058
+ onMetaChange={handleMetaChange}
1059
+ />
1060
+ )
1061
+ }
1052
1062
  />
1053
1063
  ) : railTab === 'page' ? (
1054
1064
  <div className="min-h-0 flex-1 overflow-y-auto">
@@ -1153,18 +1163,6 @@ export function PageSectionEditor({
1153
1163
  />
1154
1164
  )}
1155
1165
  </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
1166
  </div>
1169
1167
  </ReflowContext.Provider>
1170
1168
  </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
+ }