@prenta/admin 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/dist/__tests__/lib/canvas-edit-chrome.test.js +1 -22
- package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -1
- package/dist/__tests__/lib/page-audit-input.test.js +81 -0
- package/dist/__tests__/lib/page-audit-input.test.js.map +1 -1
- package/dist/__tests__/lib/preview-bridge.test.js +19 -79
- package/dist/__tests__/lib/preview-bridge.test.js.map +1 -1
- package/dist/__tests__/lib/version-history.test.js +18 -0
- package/dist/__tests__/lib/version-history.test.js.map +1 -1
- package/dist/__tests__/lib/view-site-chrome.test.js +11 -1
- package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -1
- package/dist/__tests__/no-server-barrel-imports.test.js +3 -4
- package/dist/__tests__/no-server-barrel-imports.test.js.map +1 -1
- package/dist/__tests__/views/canvas-review-mode.render.test.js +11 -1
- package/dist/__tests__/views/canvas-review-mode.render.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +13 -0
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +23 -9
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +38 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-tab.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-tab.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-tab.render.test.js +86 -0
- package/dist/__tests__/views/post-tab.render.test.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -1
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorPane.js +5 -2
- package/dist/components/seo/SeoEditorPane.js.map +1 -1
- package/dist/hooks/usePageAudit.d.ts +4 -1
- package/dist/hooks/usePageAudit.d.ts.map +1 -1
- package/dist/hooks/usePageAudit.js +3 -3
- package/dist/hooks/usePageAudit.js.map +1 -1
- package/dist/lib/canvas-edit-chrome.d.ts +0 -17
- package/dist/lib/canvas-edit-chrome.d.ts.map +1 -1
- package/dist/lib/canvas-edit-chrome.js +0 -62
- package/dist/lib/canvas-edit-chrome.js.map +1 -1
- package/dist/lib/page-audit-input.d.ts +26 -1
- package/dist/lib/page-audit-input.d.ts.map +1 -1
- package/dist/lib/page-audit-input.js +48 -2
- package/dist/lib/page-audit-input.js.map +1 -1
- package/dist/lib/preview-bridge.d.ts +7 -74
- package/dist/lib/preview-bridge.d.ts.map +1 -1
- package/dist/lib/preview-bridge.js +12 -73
- package/dist/lib/preview-bridge.js.map +1 -1
- package/dist/lib/version-history.d.ts +5 -0
- package/dist/lib/version-history.d.ts.map +1 -1
- package/dist/lib/version-history.js +18 -0
- package/dist/lib/version-history.js.map +1 -1
- package/dist/lib/view-site-chrome.d.ts +3 -0
- package/dist/lib/view-site-chrome.d.ts.map +1 -1
- package/dist/lib/view-site-chrome.js +11 -2
- package/dist/lib/view-site-chrome.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +3 -1
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +3 -2
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +8 -47
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
- package/dist/views/page-editor/sections/SectionRenderer.js +2 -1
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts +15 -2
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +2 -2
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostFieldsForm.d.ts +39 -0
- package/dist/views/post-editor/PostFieldsForm.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsForm.js +85 -0
- package/dist/views/post-editor/PostFieldsForm.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +2 -14
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
- package/dist/views/post-editor/PostFieldsModal.js +20 -81
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +128 -74
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/post-editor/PostTab.d.ts +17 -0
- package/dist/views/post-editor/PostTab.d.ts.map +1 -0
- package/dist/views/post-editor/PostTab.js +23 -0
- package/dist/views/post-editor/PostTab.js.map +1 -0
- package/dist/views/settings/SeoSettingsTab.d.ts.map +1 -1
- package/dist/views/settings/SeoSettingsTab.js +2 -1
- package/dist/views/settings/SeoSettingsTab.js.map +1 -1
- package/dist/views/settings/SiteInformationCard.d.ts.map +1 -1
- package/dist/views/settings/SiteInformationCard.js +3 -1
- package/dist/views/settings/SiteInformationCard.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/lib/canvas-edit-chrome.test.ts +0 -27
- package/src/__tests__/lib/page-audit-input.test.ts +99 -0
- package/src/__tests__/lib/preview-bridge.test.ts +21 -110
- package/src/__tests__/lib/version-history.test.ts +20 -0
- package/src/__tests__/lib/view-site-chrome.test.ts +16 -1
- package/src/__tests__/no-server-barrel-imports.test.ts +3 -4
- package/src/__tests__/views/canvas-review-mode.render.test.tsx +14 -1
- package/src/__tests__/views/editor-top-bar.render.test.tsx +25 -0
- package/src/__tests__/views/page-section-editor.test.tsx +23 -13
- package/src/__tests__/views/post-section-editor.test.tsx +59 -0
- package/src/__tests__/views/post-tab.render.test.tsx +116 -0
- package/src/components/seo/SeoEditorPane.tsx +16 -1
- package/src/hooks/usePageAudit.ts +9 -2
- package/src/lib/canvas-edit-chrome.ts +0 -64
- package/src/lib/page-audit-input.ts +67 -1
- package/src/lib/preview-bridge.ts +11 -157
- package/src/lib/version-history.ts +23 -0
- package/src/lib/view-site-chrome.ts +15 -3
- package/src/views/page-editor/EditorCanvas.tsx +1 -1
- package/src/views/page-editor/EditorTopBar.tsx +6 -2
- package/src/views/page-editor/PageSectionEditor.tsx +6 -63
- package/src/views/page-editor/sections/SectionRenderer.tsx +5 -2
- package/src/views/post-editor/PostEditorCanvas.tsx +28 -6
- package/src/views/post-editor/PostFieldsForm.tsx +322 -0
- package/src/views/post-editor/PostFieldsModal.tsx +30 -281
- package/src/views/post-editor/PostSectionEditor.tsx +209 -116
- package/src/views/post-editor/PostTab.tsx +46 -0
- package/src/views/settings/SeoSettingsTab.tsx +21 -0
- package/src/views/settings/SiteInformationCard.tsx +7 -0
|
@@ -1,128 +1,39 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
1
2
|
import { describe, expect, it } from 'vitest'
|
|
2
3
|
import {
|
|
3
|
-
canvasOriginFromSrc,
|
|
4
4
|
companionDimensionPaths,
|
|
5
|
+
fieldPathFromEventTarget,
|
|
6
|
+
fieldPathLeaf,
|
|
5
7
|
inspectorIdFromFieldPath,
|
|
6
|
-
isMediaFieldPath,
|
|
7
|
-
isPreviewBridgeMessage,
|
|
8
|
-
isPreviewEditMessage,
|
|
9
|
-
isPreviewModeMessage,
|
|
10
|
-
isPreviewRequestMediaMessage,
|
|
11
|
-
PREVIEW_BRIDGE_VERSION,
|
|
12
|
-
toPreviewSectionPayload,
|
|
13
8
|
} from '../../lib/preview-bridge.js'
|
|
14
9
|
|
|
15
|
-
describe('preview-bridge', () => {
|
|
16
|
-
it('accepts versioned prenta-preview messages', () => {
|
|
17
|
-
expect(
|
|
18
|
-
isPreviewBridgeMessage({ type: 'prenta-preview:ready', v: PREVIEW_BRIDGE_VERSION }),
|
|
19
|
-
).toBe(true)
|
|
20
|
-
expect(
|
|
21
|
-
isPreviewBridgeMessage({
|
|
22
|
-
type: 'prenta-preview:select',
|
|
23
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
24
|
-
sectionId: 'sec-1',
|
|
25
|
-
}),
|
|
26
|
-
).toBe(true)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('rejects legacy or wrong-version payloads', () => {
|
|
30
|
-
expect(isPreviewBridgeMessage({ type: 'prenta-preview-update', data: {} })).toBe(false)
|
|
31
|
-
expect(isPreviewBridgeMessage({ type: 'prenta-preview:ready', v: 99 })).toBe(false)
|
|
32
|
-
expect(isPreviewBridgeMessage(null)).toBe(false)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('parses canvas origin from iframe src', () => {
|
|
36
|
-
expect(canvasOriginFromSrc('https://cms.example.com/about/?preview=tok')).toBe(
|
|
37
|
-
'https://cms.example.com',
|
|
38
|
-
)
|
|
39
|
-
expect(canvasOriginFromSrc(null)).toBeNull()
|
|
40
|
-
})
|
|
41
|
-
|
|
10
|
+
describe('preview-bridge field paths', () => {
|
|
42
11
|
it('maps dotted field paths to unified insp-field ids', () => {
|
|
43
12
|
expect(inspectorIdFromFieldPath('lead')).toBe('insp-field-lead')
|
|
44
13
|
expect(inspectorIdFromFieldPath('stats.0.num')).toBe('insp-field-stats-0-num')
|
|
45
14
|
expect(inspectorIdFromFieldPath('rotateWords.2.word')).toBe('insp-field-rotateWords-2-word')
|
|
46
15
|
})
|
|
47
16
|
|
|
48
|
-
it('
|
|
49
|
-
expect(
|
|
50
|
-
isPreviewBridgeMessage({
|
|
51
|
-
type: 'prenta-preview:sections',
|
|
52
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
53
|
-
sections: [{ id: 's1', sectionType: 'hero', content: { heading: 'Hi' } }],
|
|
54
|
-
}),
|
|
55
|
-
).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('strips editor-only fields from preview section payloads', () => {
|
|
59
|
-
expect(
|
|
60
|
-
toPreviewSectionPayload({
|
|
61
|
-
id: 's1',
|
|
62
|
-
sectionType: 'hero',
|
|
63
|
-
name: 'Hero',
|
|
64
|
-
visible: true,
|
|
65
|
-
content: { heading: 'Hi' },
|
|
66
|
-
settings: {},
|
|
67
|
-
internalLabel: 'team note',
|
|
68
|
-
}),
|
|
69
|
-
).toEqual({
|
|
70
|
-
id: 's1',
|
|
71
|
-
sectionType: 'hero',
|
|
72
|
-
name: 'Hero',
|
|
73
|
-
visible: true,
|
|
74
|
-
content: { heading: 'Hi' },
|
|
75
|
-
settings: {},
|
|
76
|
-
unmanaged: undefined,
|
|
77
|
-
})
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
it('validates edit / mode / request-media payloads', () => {
|
|
81
|
-
expect(
|
|
82
|
-
isPreviewEditMessage({
|
|
83
|
-
type: 'prenta-preview:edit',
|
|
84
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
85
|
-
sectionId: 's1',
|
|
86
|
-
fieldPath: 'lead',
|
|
87
|
-
value: 'Hello',
|
|
88
|
-
}),
|
|
89
|
-
).toBe(true)
|
|
90
|
-
expect(
|
|
91
|
-
isPreviewEditMessage({
|
|
92
|
-
type: 'prenta-preview:edit',
|
|
93
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
94
|
-
sectionId: 's1',
|
|
95
|
-
fieldPath: 'lead',
|
|
96
|
-
}),
|
|
97
|
-
).toBe(false)
|
|
98
|
-
expect(
|
|
99
|
-
isPreviewModeMessage({
|
|
100
|
-
type: 'prenta-preview:mode',
|
|
101
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
102
|
-
editing: true,
|
|
103
|
-
}),
|
|
104
|
-
).toBe(true)
|
|
105
|
-
expect(
|
|
106
|
-
isPreviewRequestMediaMessage({
|
|
107
|
-
type: 'prenta-preview:request-media',
|
|
108
|
-
v: PREVIEW_BRIDGE_VERSION,
|
|
109
|
-
sectionId: 's1',
|
|
110
|
-
fieldPath: 'awards.0.imageUrl',
|
|
111
|
-
}),
|
|
112
|
-
).toBe(true)
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('detects media field paths and companion dimension paths', () => {
|
|
116
|
-
expect(isMediaFieldPath('imageUrl')).toBe(true)
|
|
117
|
-
expect(isMediaFieldPath('awards.0.imageUrl')).toBe(true)
|
|
118
|
-
expect(isMediaFieldPath('lead')).toBe(false)
|
|
119
|
-
expect(isMediaFieldPath('ctaUrl')).toBe(false)
|
|
120
|
-
expect(isMediaFieldPath('url', 'media')).toBe(true)
|
|
121
|
-
expect(isMediaFieldPath('images.0.url')).toBe(true)
|
|
122
|
-
expect(isMediaFieldPath('buttonUrl')).toBe(false)
|
|
17
|
+
it('reads companion dimension paths for media URL fields', () => {
|
|
123
18
|
expect(companionDimensionPaths('awards.0.imageUrl')).toEqual({
|
|
124
19
|
widthPath: 'awards.0.imageWidth',
|
|
125
20
|
heightPath: 'awards.0.imageHeight',
|
|
126
21
|
})
|
|
22
|
+
expect(companionDimensionPaths('images.0.url')).toEqual({
|
|
23
|
+
widthPath: 'images.0.width',
|
|
24
|
+
heightPath: 'images.0.height',
|
|
25
|
+
})
|
|
26
|
+
expect(fieldPathLeaf('awards.0.imageUrl')).toBe('imageUrl')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('reads data-cms-field from a click target or its ancestor', () => {
|
|
30
|
+
const wrap = document.createElement('div')
|
|
31
|
+
wrap.setAttribute('data-cms-field', 'heading')
|
|
32
|
+
const inner = document.createElement('span')
|
|
33
|
+
wrap.appendChild(inner)
|
|
34
|
+
expect(fieldPathFromEventTarget(inner)).toBe('heading')
|
|
35
|
+
expect(fieldPathFromEventTarget(wrap)).toBe('heading')
|
|
36
|
+
expect(fieldPathFromEventTarget(document.createElement('div'))).toBeUndefined()
|
|
37
|
+
expect(fieldPathFromEventTarget(null)).toBeUndefined()
|
|
127
38
|
})
|
|
128
39
|
})
|
|
@@ -40,6 +40,26 @@ describe('toPageVersion', () => {
|
|
|
40
40
|
expect(mapped.author.name).toBe('Sarah Mills')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
|
+
it('lifts post identity fields so canvas review can show the snapshot header', () => {
|
|
44
|
+
const mapped = toPageVersion({
|
|
45
|
+
id: 'v1',
|
|
46
|
+
changeType: 'UPDATE',
|
|
47
|
+
createdAt: '2026-08-02T12:00:00Z',
|
|
48
|
+
data: {
|
|
49
|
+
title: 'Snapshot title',
|
|
50
|
+
excerpt: 'Snapshot excerpt',
|
|
51
|
+
featuredImage: { url: 'https://cdn.example.com/hero.webp' },
|
|
52
|
+
body: '<p>Snapshot body</p>',
|
|
53
|
+
sections: [],
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
expect(mapped.title).toBe('Snapshot title')
|
|
58
|
+
expect(mapped.excerpt).toBe('Snapshot excerpt')
|
|
59
|
+
expect(mapped.featuredImage).toBe('https://cdn.example.com/hero.webp')
|
|
60
|
+
expect(mapped.body).toBe('<p>Snapshot body</p>')
|
|
61
|
+
})
|
|
62
|
+
|
|
43
63
|
it('preserves an unmanaged section rather than dropping it from the snapshot', () => {
|
|
44
64
|
// Coercing a snapshot more strictly than the live document reads would
|
|
45
65
|
// make an externally-managed section look deleted in every version.
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
ADMIN_ORIGIN_SITE_URL_REASON,
|
|
4
|
+
NO_PUBLIC_SITE_URL_REASON,
|
|
5
|
+
resolveViewSiteChrome,
|
|
6
|
+
} from '../../lib/view-site-chrome.js'
|
|
3
7
|
|
|
4
8
|
const base = {
|
|
5
9
|
dirty: false,
|
|
@@ -53,6 +57,17 @@ describe('resolveViewSiteChrome', () => {
|
|
|
53
57
|
expect(chrome.shareVisible).toBe(true)
|
|
54
58
|
})
|
|
55
59
|
|
|
60
|
+
it('disables View site and Share when the site URL is the admin origin', () => {
|
|
61
|
+
const chrome = resolveViewSiteChrome({
|
|
62
|
+
...base,
|
|
63
|
+
status: 'DRAFT',
|
|
64
|
+
matchesAdminOrigin: true,
|
|
65
|
+
})
|
|
66
|
+
expect(chrome.viewEnabled).toBe(false)
|
|
67
|
+
expect(chrome.viewDisabledReason).toBe(ADMIN_ORIGIN_SITE_URL_REASON)
|
|
68
|
+
expect(chrome.shareVisible).toBe(true)
|
|
69
|
+
})
|
|
70
|
+
|
|
56
71
|
it('hides View site and Share when the document is unsaved', () => {
|
|
57
72
|
const chrome = resolveViewSiteChrome({
|
|
58
73
|
...base,
|
|
@@ -9,9 +9,8 @@ import { describe, expect, it } from 'vitest'
|
|
|
9
9
|
// the bug Opal hit where Turbopack pulled `pg`/`fs` into the browser build.
|
|
10
10
|
//
|
|
11
11
|
// Type-only imports (`import type` / `export type`) are erased by tsc and are
|
|
12
|
-
// safe.
|
|
13
|
-
//
|
|
14
|
-
// any source file reintroduces a value import from the bare barrel.
|
|
12
|
+
// safe. This test fails if any source file reintroduces a value import from
|
|
13
|
+
// the bare barrel.
|
|
15
14
|
|
|
16
15
|
const SRC_DIR = fileURLToPath(new URL('../', import.meta.url))
|
|
17
16
|
|
|
@@ -52,7 +51,7 @@ describe('cms-admin client/server bundle boundary', () => {
|
|
|
52
51
|
|
|
53
52
|
expect(
|
|
54
53
|
offenders,
|
|
55
|
-
`Use '@prenta/core
|
|
54
|
+
`Use 'import type' from '@prenta/core' or a client-safe subpath instead:\n${offenders.join('\n')}`,
|
|
56
55
|
).toEqual([])
|
|
57
56
|
})
|
|
58
57
|
})
|
|
@@ -68,10 +68,23 @@ describe('SectionRenderer — review mode', () => {
|
|
|
68
68
|
|
|
69
69
|
fireEvent.click(sectionEl())
|
|
70
70
|
|
|
71
|
-
expect(onSelect).toHaveBeenCalledWith('s1')
|
|
71
|
+
expect(onSelect).toHaveBeenCalledWith('s1', undefined)
|
|
72
72
|
expect(sectionEl().getAttribute('data-editable')).toBe('true')
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
+
it('passes the data-cms-field path when a field inside the section is clicked', () => {
|
|
76
|
+
const onSelect = vi.fn()
|
|
77
|
+
render(<SectionRenderer section={SECTION} editable onSelect={onSelect} />)
|
|
78
|
+
|
|
79
|
+
const field = document.createElement('h2')
|
|
80
|
+
field.setAttribute('data-cms-field', 'heading')
|
|
81
|
+
field.textContent = 'A headline'
|
|
82
|
+
sectionEl().appendChild(field)
|
|
83
|
+
fireEvent.click(field)
|
|
84
|
+
|
|
85
|
+
expect(onSelect).toHaveBeenCalledWith('s1', 'heading')
|
|
86
|
+
})
|
|
87
|
+
|
|
75
88
|
it('names the change in the accessible name, not only in colour and a dashed border', () => {
|
|
76
89
|
render(<SectionRenderer section={SECTION} editable reviewing changed />)
|
|
77
90
|
expect(sectionEl().getAttribute('aria-label')).toBe(
|
|
@@ -151,6 +151,25 @@ describe('EditorTopBar view site / share', () => {
|
|
|
151
151
|
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
|
|
152
152
|
})
|
|
153
153
|
|
|
154
|
+
it('disables View site and Share when the site URL is the admin origin', () => {
|
|
155
|
+
render(
|
|
156
|
+
<EditorTopBar
|
|
157
|
+
{...baseProps}
|
|
158
|
+
onViewOnSite={vi.fn()}
|
|
159
|
+
onShare={vi.fn()}
|
|
160
|
+
hasPublicSiteUrl
|
|
161
|
+
matchesAdminOrigin
|
|
162
|
+
/>,
|
|
163
|
+
)
|
|
164
|
+
const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
|
|
165
|
+
const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
|
|
166
|
+
expect(view.disabled).toBe(true)
|
|
167
|
+
expect(share.disabled).toBe(true)
|
|
168
|
+
expect(view.getAttribute('title')).toBe(
|
|
169
|
+
'This origin is the admin, not the public site. Set the public site URL in Settings → SEO.',
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
154
173
|
it('disables View site and Share when the public site URL is missing', () => {
|
|
155
174
|
render(
|
|
156
175
|
<EditorTopBar
|
|
@@ -213,4 +232,10 @@ describe('EditorTopBar history button', () => {
|
|
|
213
232
|
expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
|
|
214
233
|
}
|
|
215
234
|
})
|
|
235
|
+
|
|
236
|
+
it('labels the gear Post settings when the editor is a post', () => {
|
|
237
|
+
render(<EditorTopBar {...baseProps} backLabel="Posts" />)
|
|
238
|
+
expect(screen.getByRole('button', { name: 'Post settings' })).toBeTruthy()
|
|
239
|
+
expect(screen.queryByRole('button', { name: 'Page settings' })).toBeNull()
|
|
240
|
+
})
|
|
216
241
|
})
|
|
@@ -213,12 +213,22 @@ describe('PageSectionEditor section disclosure', () => {
|
|
|
213
213
|
expect(screen.queryByLabelText('Section name')).toBeNull()
|
|
214
214
|
})
|
|
215
215
|
|
|
216
|
-
it('keeps the row open when
|
|
216
|
+
it('keeps the row open when a canvas field click targets the same section', async () => {
|
|
217
217
|
// A `fieldPath` means "the editor clicked THIS field" — toggling there
|
|
218
|
-
// would close the form on the second click of a field being
|
|
219
|
-
fetchPageForEditor.mockResolvedValue(
|
|
220
|
-
loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
|
|
221
|
-
|
|
218
|
+
// would close the form on the second click of a field being inspected.
|
|
219
|
+
fetchPageForEditor.mockResolvedValue({
|
|
220
|
+
...loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }]),
|
|
221
|
+
sections: [
|
|
222
|
+
{
|
|
223
|
+
id: 's1',
|
|
224
|
+
sectionType: 'hero',
|
|
225
|
+
name: 'Hero',
|
|
226
|
+
visible: true,
|
|
227
|
+
content: { heading: 'Welcome' },
|
|
228
|
+
settings: {},
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
})
|
|
222
232
|
render(
|
|
223
233
|
<PageSectionEditor documentId="p1" config={validConfig} onNavigate={() => {}} session={{}} />,
|
|
224
234
|
)
|
|
@@ -227,14 +237,14 @@ describe('PageSectionEditor section disclosure', () => {
|
|
|
227
237
|
fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
|
|
228
238
|
await screen.findByRole('button', { name: 'Close Hero' })
|
|
229
239
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
240
|
+
const section = document.querySelector('[data-section-id="s1"]')
|
|
241
|
+
expect(section).toBeTruthy()
|
|
242
|
+
const field = document.createElement('h2')
|
|
243
|
+
field.setAttribute('data-cms-field', 'heading')
|
|
244
|
+
field.textContent = 'Welcome'
|
|
245
|
+
section?.appendChild(field)
|
|
246
|
+
fireEvent.click(field)
|
|
247
|
+
fireEvent.click(field)
|
|
238
248
|
|
|
239
249
|
expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
|
|
240
250
|
})
|
|
@@ -51,6 +51,7 @@ function loadedPost(
|
|
|
51
51
|
featuredImage: '',
|
|
52
52
|
body: '',
|
|
53
53
|
category: '',
|
|
54
|
+
tags: [],
|
|
54
55
|
status: 'DRAFT',
|
|
55
56
|
publishDate: null,
|
|
56
57
|
sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
|
|
@@ -276,6 +277,64 @@ describe('PostSectionEditor rail', () => {
|
|
|
276
277
|
|
|
277
278
|
expect(screen.getByRole('tab', { name: /Sections/ })).toBeTruthy()
|
|
278
279
|
expect(screen.getByRole('tab', { name: /SEO/ })).toBeTruthy()
|
|
280
|
+
expect(screen.getByRole('tab', { name: 'Post' })).toBeTruthy()
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('live-applies Post tab edits onto the editor', async () => {
|
|
284
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
285
|
+
fetchPostForEditor.mockResolvedValue(
|
|
286
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
287
|
+
)
|
|
288
|
+
render(
|
|
289
|
+
<PostSectionEditor
|
|
290
|
+
postType="blog"
|
|
291
|
+
documentId="p1"
|
|
292
|
+
config={validConfig}
|
|
293
|
+
onNavigate={() => {}}
|
|
294
|
+
session={{ user: { role: 'EDITOR' } }}
|
|
295
|
+
/>,
|
|
296
|
+
)
|
|
297
|
+
await screen.findByRole('tablist', { name: 'Editor panels' })
|
|
298
|
+
|
|
299
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Post' }))
|
|
300
|
+
const title = document.getElementById('post-title') as HTMLInputElement
|
|
301
|
+
fireEvent.change(title, { target: { value: 'Renamed in rail' } })
|
|
302
|
+
expect(screen.getAllByText('Renamed in rail').length).toBeGreaterThan(0)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('inlines post fields on the Post tab, and the gear still opens the modal', async () => {
|
|
306
|
+
await renderLoaded()
|
|
307
|
+
|
|
308
|
+
fireEvent.click(screen.getByRole('tab', { name: 'Post' }))
|
|
309
|
+
const panel = document.getElementById('rail-panel-page')
|
|
310
|
+
expect(panel).toBeTruthy()
|
|
311
|
+
expect(panel!.querySelector('#post-title')).toBeTruthy()
|
|
312
|
+
expect(panel!.querySelector('#post-slug')).toBeTruthy()
|
|
313
|
+
expect(panel!.querySelector('#post-excerpt')).toBeTruthy()
|
|
314
|
+
expect(screen.queryByRole('dialog', { name: 'Post details' })).toBeNull()
|
|
315
|
+
|
|
316
|
+
fireEvent.click(screen.getByRole('button', { name: 'Post settings' }))
|
|
317
|
+
expect(screen.getByRole('dialog', { name: 'Post details' })).toBeTruthy()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('puts the audit score on the SEO tab and the header chip', async () => {
|
|
321
|
+
await renderLoaded()
|
|
322
|
+
|
|
323
|
+
expect(
|
|
324
|
+
screen.getByRole('tab', { name: /SEO(?:.|\s)*(score \d+ of 100|not rated)/i }),
|
|
325
|
+
).toBeTruthy()
|
|
326
|
+
expect(screen.getByRole('button', { name: /SEO(?:, score \d+ out of 100)?/ })).toBeTruthy()
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it('presses the History button while the overlay is open', async () => {
|
|
330
|
+
await renderLoaded()
|
|
331
|
+
|
|
332
|
+
const history = screen.getByRole('button', { name: 'Version history' })
|
|
333
|
+
expect(history.getAttribute('aria-pressed')).toBe('false')
|
|
334
|
+
fireEvent.click(history)
|
|
335
|
+
expect(history.getAttribute('aria-pressed')).toBe('true')
|
|
336
|
+
expect(screen.getByRole('region', { name: 'Version history' })).toBeTruthy()
|
|
337
|
+
expect(screen.queryByRole('dialog', { name: /version history/i })).toBeNull()
|
|
279
338
|
})
|
|
280
339
|
|
|
281
340
|
it('opens SEO inside the rail, not as a second panel beside it', async () => {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import type { EditorPost } from '../../lib/post-editor-service.js'
|
|
5
|
+
|
|
6
|
+
const cmsApi = vi.fn(async () => ({ data: { field: 'tags', values: [] }, status: 200 }))
|
|
7
|
+
|
|
8
|
+
vi.mock('../../lib/api.js', () => ({
|
|
9
|
+
cmsApi: (...args: unknown[]) => cmsApi(...(args as [])),
|
|
10
|
+
ensureCsrfToken: vi.fn(async () => {}),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
vi.mock('../../lib/coauthor-client.js', () => ({
|
|
14
|
+
coauthorText: vi.fn(async () => ({
|
|
15
|
+
ok: true,
|
|
16
|
+
result: { action: 'compress', text: 'AI excerpt' },
|
|
17
|
+
})),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
const { PostTab } = await import('../../views/post-editor/PostTab.js')
|
|
21
|
+
|
|
22
|
+
function basePost(over: Partial<EditorPost> = {}): EditorPost {
|
|
23
|
+
return {
|
|
24
|
+
id: 'p1',
|
|
25
|
+
postType: 'blog',
|
|
26
|
+
title: 'Hello',
|
|
27
|
+
slug: 'hello',
|
|
28
|
+
excerpt: 'A short summary.',
|
|
29
|
+
featuredImage: '',
|
|
30
|
+
body: '',
|
|
31
|
+
category: '',
|
|
32
|
+
tags: [],
|
|
33
|
+
status: 'DRAFT',
|
|
34
|
+
publishDate: null,
|
|
35
|
+
scheduledAt: null,
|
|
36
|
+
scheduledUnpublishAt: null,
|
|
37
|
+
sections: [],
|
|
38
|
+
seoTitle: '',
|
|
39
|
+
seoDescription: '',
|
|
40
|
+
publishedAt: null,
|
|
41
|
+
updatedAt: null,
|
|
42
|
+
...over,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SELECT_FIELDS = {
|
|
47
|
+
category: {
|
|
48
|
+
type: 'select' as const,
|
|
49
|
+
options: [
|
|
50
|
+
{ label: 'News', value: 'news' },
|
|
51
|
+
{ label: 'Tutorials', value: 'tutorials' },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
tags: { type: 'array' as const, maxRows: 12 },
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
cmsApi.mockClear()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('PostTab', () => {
|
|
62
|
+
it('live-applies title, slug, and excerpt without a dialog', () => {
|
|
63
|
+
const onChange = vi.fn()
|
|
64
|
+
render(<PostTab post={basePost()} canEdit fields={SELECT_FIELDS} onChange={onChange} />)
|
|
65
|
+
|
|
66
|
+
expect(screen.queryByRole('dialog')).toBeNull()
|
|
67
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Renamed' } })
|
|
68
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ title: 'Renamed' }))
|
|
69
|
+
|
|
70
|
+
fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
|
|
71
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ slug: 'custom-slug' }))
|
|
72
|
+
|
|
73
|
+
fireEvent.change(screen.getByLabelText(/^Excerpt/), { target: { value: 'New excerpt' } })
|
|
74
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ excerpt: 'New excerpt' }))
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('derives slug from title until the slug field is edited', () => {
|
|
78
|
+
const onChange = vi.fn()
|
|
79
|
+
const { rerender } = render(
|
|
80
|
+
<PostTab post={basePost({ title: '', slug: '' })} canEdit onChange={onChange} />,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Hello World' } })
|
|
84
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
85
|
+
expect.objectContaining({ title: 'Hello World', slug: 'hello-world' }),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
rerender(
|
|
89
|
+
<PostTab
|
|
90
|
+
post={basePost({ title: 'Hello World', slug: 'hello-world' })}
|
|
91
|
+
canEdit
|
|
92
|
+
onChange={onChange}
|
|
93
|
+
/>,
|
|
94
|
+
)
|
|
95
|
+
fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
|
|
96
|
+
onChange.mockClear()
|
|
97
|
+
rerender(
|
|
98
|
+
<PostTab
|
|
99
|
+
post={basePost({ title: 'Hello World', slug: 'custom-slug' })}
|
|
100
|
+
canEdit
|
|
101
|
+
onChange={onChange}
|
|
102
|
+
/>,
|
|
103
|
+
)
|
|
104
|
+
fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed Title' } })
|
|
105
|
+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ title: 'Changed Title' }))
|
|
106
|
+
expect(onChange).not.toHaveBeenCalledWith(expect.objectContaining({ slug: expect.anything() }))
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not offer page-only controls', () => {
|
|
110
|
+
render(<PostTab post={basePost()} canEdit onChange={() => {}} />)
|
|
111
|
+
expect(screen.queryByText(/301 redirect/)).toBeNull()
|
|
112
|
+
expect(screen.queryByText(/Danger zone/)).toBeNull()
|
|
113
|
+
expect(screen.queryByText(/Search directives/)).toBeNull()
|
|
114
|
+
expect(screen.queryByLabelText(/URL slug/)).toBeNull()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -154,6 +154,14 @@ export interface SeoEditorPaneProps {
|
|
|
154
154
|
slug?: string
|
|
155
155
|
draftMeta?: { seoTitle: string; seoDescription: string }
|
|
156
156
|
onDraftChange?: (patch: { seoTitle?: string; seoDescription?: string }) => void
|
|
157
|
+
/**
|
|
158
|
+
* Post identity the template renders outside the section list. Folded into
|
|
159
|
+
* the same `buildPageAuditInput` path the saved-record audit uses, so live
|
|
160
|
+
* rings and the header chip cannot disagree on H1 / opening / featured image.
|
|
161
|
+
*/
|
|
162
|
+
title?: string
|
|
163
|
+
bodyHtml?: string
|
|
164
|
+
featuredImage?: string
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
export function SeoEditorPane({
|
|
@@ -167,6 +175,9 @@ export function SeoEditorPane({
|
|
|
167
175
|
slug = '',
|
|
168
176
|
draftMeta,
|
|
169
177
|
onDraftChange,
|
|
178
|
+
title,
|
|
179
|
+
bodyHtml,
|
|
180
|
+
featuredImage,
|
|
170
181
|
}: SeoEditorPaneProps) {
|
|
171
182
|
const isDraft = !entityId
|
|
172
183
|
const [record, setRecord] = useState<SeoContentRecord | null>(null)
|
|
@@ -471,9 +482,13 @@ export function SeoEditorPane({
|
|
|
471
482
|
slug,
|
|
472
483
|
record,
|
|
473
484
|
fallbackTitle: draftMeta?.seoTitle,
|
|
485
|
+
post:
|
|
486
|
+
title !== undefined || bodyHtml !== undefined || featuredImage !== undefined
|
|
487
|
+
? { title, bodyHtml, featuredImage }
|
|
488
|
+
: undefined,
|
|
474
489
|
}),
|
|
475
490
|
)
|
|
476
|
-
}, [auditSections, form, slug, record, draftMeta?.seoTitle])
|
|
491
|
+
}, [auditSections, form, slug, record, draftMeta?.seoTitle, title, bodyHtml, featuredImage])
|
|
477
492
|
|
|
478
493
|
useEffect(() => {
|
|
479
494
|
onAuditChange?.(audit)
|
|
@@ -4,7 +4,11 @@ import { useCallback, useEffect, useState } from 'react'
|
|
|
4
4
|
import type { PageSection } from '@prenta/core/page-sections'
|
|
5
5
|
import { auditPage, type PageAuditResult } from '@prenta/core/seo/page-audit'
|
|
6
6
|
import { fetchSeoContentRecord, type SeoContentRecord } from '../lib/seo-service.js'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
buildPageAuditInput,
|
|
9
|
+
metaFromRecord,
|
|
10
|
+
type PostAuditExtras,
|
|
11
|
+
} from '../lib/page-audit-input.js'
|
|
8
12
|
|
|
9
13
|
export interface UsePageAuditArgs {
|
|
10
14
|
entityType: 'page' | 'post'
|
|
@@ -19,6 +23,8 @@ export interface UsePageAuditArgs {
|
|
|
19
23
|
* exactly the pages that most need the nudge.
|
|
20
24
|
*/
|
|
21
25
|
fallback?: { title?: string; description?: string }
|
|
26
|
+
/** Post identity the template renders outside the section list. */
|
|
27
|
+
post?: PostAuditExtras
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
@@ -42,6 +48,7 @@ export function usePageAudit({
|
|
|
42
48
|
sections,
|
|
43
49
|
slug,
|
|
44
50
|
fallback,
|
|
51
|
+
post,
|
|
45
52
|
}: UsePageAuditArgs): {
|
|
46
53
|
audit: PageAuditResult
|
|
47
54
|
record: SeoContentRecord | null
|
|
@@ -90,7 +97,7 @@ export function usePageAudit({
|
|
|
90
97
|
hasOgImage: false,
|
|
91
98
|
}
|
|
92
99
|
|
|
93
|
-
const audit = auditPage(buildPageAuditInput({ sections, meta, slug, record }))
|
|
100
|
+
const audit = auditPage(buildPageAuditInput({ sections, meta, slug, record, post }))
|
|
94
101
|
|
|
95
102
|
const reload = useCallback(() => setNonce((n) => n + 1), [])
|
|
96
103
|
|
|
@@ -1,70 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared edit affordances for the page/post canvas and Site preview iframe.
|
|
3
|
-
*
|
|
4
|
-
* Structure canvas: section hover is styled in `theme.css` under `.prenta-canvas`.
|
|
5
|
-
* Site preview: the same field chrome is injected into the iframe document when
|
|
6
|
-
* same-origin (CMS-host preview) so consumers get hover borders without shipping CSS.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
/** localStorage key for the dismissible canvas tip. */
|
|
10
2
|
export const CANVAS_EDIT_TIP_STORAGE_KEY = 'prenta.canvasEditTip.dismissed'
|
|
11
3
|
|
|
12
|
-
/**
|
|
13
|
-
* CSS injected into the Site preview iframe while editing is enabled.
|
|
14
|
-
* Uses system colors so it works without admin theme tokens in the iframe.
|
|
15
|
-
*/
|
|
16
|
-
export const PREVIEW_EDITABLE_FIELD_CHROME_CSS = `
|
|
17
|
-
/* Prenta Site preview — editable field hover chrome (injected by @prenta/admin) */
|
|
18
|
-
[data-cms-field] {
|
|
19
|
-
outline: 1px solid transparent;
|
|
20
|
-
outline-offset: 2px;
|
|
21
|
-
border-radius: 2px;
|
|
22
|
-
transition: outline-color 120ms ease;
|
|
23
|
-
cursor: text;
|
|
24
|
-
}
|
|
25
|
-
[data-cms-field]:hover,
|
|
26
|
-
[data-cms-field]:focus,
|
|
27
|
-
[data-cms-field]:focus-within {
|
|
28
|
-
outline-color: color-mix(in oklab, CanvasText 24%, transparent);
|
|
29
|
-
}
|
|
30
|
-
[data-cms-field][data-cms-field-kind="media"] {
|
|
31
|
-
cursor: pointer;
|
|
32
|
-
}
|
|
33
|
-
[data-cms-field][data-cms-field-kind="media"]:hover,
|
|
34
|
-
[data-cms-field][data-cms-field-kind="media"]:focus {
|
|
35
|
-
outline-color: color-mix(in oklab, CanvasText 32%, transparent);
|
|
36
|
-
}
|
|
37
|
-
@media (prefers-reduced-motion: reduce) {
|
|
38
|
-
[data-cms-field] {
|
|
39
|
-
transition: none;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
`.trim()
|
|
43
|
-
|
|
44
|
-
const STYLE_ATTR = 'data-prenta-edit-chrome'
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Inject (or refresh) editable-field hover chrome into a same-origin preview
|
|
48
|
-
* iframe. No-ops on cross-origin frames (browser blocks contentDocument).
|
|
49
|
-
*/
|
|
50
|
-
export function injectPreviewEditChrome(iframe: HTMLIFrameElement | null): boolean {
|
|
51
|
-
try {
|
|
52
|
-
const doc = iframe?.contentDocument
|
|
53
|
-
if (!doc?.head) return false
|
|
54
|
-
let style = doc.head.querySelector<HTMLStyleElement>(`style[${STYLE_ATTR}]`)
|
|
55
|
-
if (!style) {
|
|
56
|
-
style = doc.createElement('style')
|
|
57
|
-
style.setAttribute(STYLE_ATTR, '1')
|
|
58
|
-
doc.head.appendChild(style)
|
|
59
|
-
}
|
|
60
|
-
style.textContent = PREVIEW_EDITABLE_FIELD_CHROME_CSS
|
|
61
|
-
doc.documentElement.setAttribute('data-prenta-preview-editing', 'true')
|
|
62
|
-
return true
|
|
63
|
-
} catch {
|
|
64
|
-
return false
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
4
|
export function isCanvasEditTipDismissed(): boolean {
|
|
69
5
|
try {
|
|
70
6
|
return localStorage.getItem(CANVAS_EDIT_TIP_STORAGE_KEY) === '1'
|