@prenta/admin 1.0.0 → 1.1.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 +6 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +5 -56
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/breakpoint-controls.render.test.js +4 -4
- package/dist/__tests__/components/breakpoint-controls.render.test.js.map +1 -1
- package/dist/__tests__/lib/open-public-site-tab.test.d.ts +2 -0
- package/dist/__tests__/lib/open-public-site-tab.test.d.ts.map +1 -0
- package/dist/__tests__/lib/open-public-site-tab.test.js +64 -0
- package/dist/__tests__/lib/open-public-site-tab.test.js.map +1 -0
- package/dist/__tests__/lib/view-site-chrome.test.d.ts +2 -0
- package/dist/__tests__/lib/view-site-chrome.test.d.ts.map +1 -0
- package/dist/__tests__/lib/view-site-chrome.test.js +59 -0
- package/dist/__tests__/lib/view-site-chrome.test.js.map +1 -0
- package/dist/__tests__/router/admin-routes.test.js +4 -0
- package/dist/__tests__/router/admin-routes.test.js.map +1 -1
- package/dist/__tests__/views/canvas-surface-shared.test.js +0 -16
- package/dist/__tests__/views/canvas-surface-shared.test.js.map +1 -1
- package/dist/__tests__/views/editor-top-bar.render.test.js +44 -22
- package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
- package/dist/__tests__/views/page-section-editor.test.js +30 -9
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -1
- package/dist/__tests__/views/post-section-editor.test.js +23 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -1
- package/dist/components/BreakpointPicker.d.ts +3 -2
- package/dist/components/BreakpointPicker.d.ts.map +1 -1
- package/dist/components/BreakpointPicker.js +5 -3
- package/dist/components/BreakpointPicker.js.map +1 -1
- package/dist/components/CanvasEditTip.js +1 -1
- package/dist/components/CanvasEditTip.js.map +1 -1
- package/dist/lib/editor-preview.d.ts +4 -1
- package/dist/lib/editor-preview.d.ts.map +1 -1
- package/dist/lib/editor-preview.js +4 -1
- package/dist/lib/editor-preview.js.map +1 -1
- package/dist/lib/open-public-site-tab.d.ts +14 -0
- package/dist/lib/open-public-site-tab.d.ts.map +1 -0
- package/dist/lib/open-public-site-tab.js +46 -0
- package/dist/lib/open-public-site-tab.js.map +1 -0
- package/dist/lib/view-site-chrome.d.ts +16 -0
- package/dist/lib/view-site-chrome.d.ts.map +1 -0
- package/dist/lib/view-site-chrome.js +15 -0
- package/dist/lib/view-site-chrome.js.map +1 -0
- package/dist/router/admin-routes.d.ts.map +1 -1
- package/dist/router/admin-routes.js +0 -2
- package/dist/router/admin-routes.js.map +1 -1
- package/dist/views/page-editor/EditorCanvas.d.ts +1 -2
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
- package/dist/views/page-editor/EditorCanvas.js +2 -2
- package/dist/views/page-editor/EditorCanvas.js.map +1 -1
- package/dist/views/page-editor/EditorTopBar.d.ts +13 -17
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
- package/dist/views/page-editor/EditorTopBar.js +19 -21
- package/dist/views/page-editor/EditorTopBar.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts +1 -7
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +29 -95
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/StructureNotice.d.ts +2 -5
- package/dist/views/page-editor/StructureNotice.d.ts.map +1 -1
- package/dist/views/page-editor/StructureNotice.js +6 -10
- package/dist/views/page-editor/StructureNotice.js.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.d.ts +1 -3
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
- package/dist/views/post-editor/PostEditorCanvas.js +3 -3
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts +1 -15
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +30 -88
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/package.json +4 -4
- package/src/AdminRoot.tsx +0 -70
- package/src/__tests__/components/breakpoint-controls.render.test.tsx +6 -4
- package/src/__tests__/lib/open-public-site-tab.test.ts +74 -0
- package/src/__tests__/lib/view-site-chrome.test.ts +65 -0
- package/src/__tests__/router/admin-routes.test.ts +5 -0
- package/src/__tests__/views/canvas-surface-shared.test.ts +0 -19
- package/src/__tests__/views/editor-top-bar.render.test.tsx +85 -22
- package/src/__tests__/views/page-section-editor.test.tsx +42 -9
- package/src/__tests__/views/post-section-editor.test.tsx +35 -0
- package/src/components/BreakpointPicker.tsx +6 -3
- package/src/components/CanvasEditTip.tsx +2 -2
- package/src/lib/editor-preview.ts +4 -1
- package/src/lib/open-public-site-tab.ts +57 -0
- package/src/lib/view-site-chrome.ts +34 -0
- package/src/router/admin-routes.ts +0 -2
- package/src/styles/theme.css +4 -4
- package/src/views/page-editor/EditorCanvas.tsx +1 -6
- package/src/views/page-editor/EditorTopBar.tsx +81 -82
- package/src/views/page-editor/PageSectionEditor.tsx +77 -157
- package/src/views/page-editor/StructureNotice.tsx +7 -42
- package/src/views/post-editor/PostEditorCanvas.tsx +2 -8
- package/src/views/post-editor/PostSectionEditor.tsx +66 -138
- package/dist/__tests__/views/use-site-preview.render.test.d.ts +0 -2
- package/dist/__tests__/views/use-site-preview.render.test.d.ts.map +0 -1
- package/dist/__tests__/views/use-site-preview.render.test.js +0 -120
- package/dist/__tests__/views/use-site-preview.render.test.js.map +0 -1
- package/dist/views/page-editor/PagePreview.d.ts +0 -13
- package/dist/views/page-editor/PagePreview.d.ts.map +0 -1
- package/dist/views/page-editor/PagePreview.js +0 -46
- package/dist/views/page-editor/PagePreview.js.map +0 -1
- package/dist/views/page-editor/SitePreviewFrame.d.ts +0 -43
- package/dist/views/page-editor/SitePreviewFrame.d.ts.map +0 -1
- package/dist/views/page-editor/SitePreviewFrame.js +0 -165
- package/dist/views/page-editor/SitePreviewFrame.js.map +0 -1
- package/dist/views/page-editor/useSitePreview.d.ts +0 -47
- package/dist/views/page-editor/useSitePreview.d.ts.map +0 -1
- package/dist/views/page-editor/useSitePreview.js +0 -96
- package/dist/views/page-editor/useSitePreview.js.map +0 -1
- package/dist/views/post-editor/PostPreview.d.ts +0 -14
- package/dist/views/post-editor/PostPreview.d.ts.map +0 -1
- package/dist/views/post-editor/PostPreview.js +0 -63
- package/dist/views/post-editor/PostPreview.js.map +0 -1
- package/src/__tests__/views/use-site-preview.render.test.tsx +0 -164
- package/src/views/page-editor/PagePreview.tsx +0 -105
- package/src/views/page-editor/SitePreviewFrame.tsx +0 -250
- package/src/views/page-editor/useSitePreview.ts +0 -158
- package/src/views/post-editor/PostPreview.tsx +0 -125
|
@@ -5,11 +5,13 @@ import { BreakpointPicker } from '../../components/BreakpointPicker.js'
|
|
|
5
5
|
import { ZoomChip } from '../../components/ZoomChip.js'
|
|
6
6
|
|
|
7
7
|
describe('BreakpointPicker', () => {
|
|
8
|
-
it('names
|
|
8
|
+
it('names canvas width, not a live preview', () => {
|
|
9
9
|
render(<BreakpointPicker value="tabletL" onChange={vi.fn()} />)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
expect(
|
|
11
|
+
screen.getByRole('button', {
|
|
12
|
+
name: 'Canvas width: Tablet Landscape, 1024 pixels. First-party layout only — use View site for the live design.',
|
|
13
|
+
}),
|
|
14
|
+
).toBeTruthy()
|
|
13
15
|
})
|
|
14
16
|
|
|
15
17
|
it('renders 4K at 2560, not the physical pixel count', () => {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
const createPreviewToken = vi.fn()
|
|
4
|
+
vi.mock('../../lib/preview-link.js', async (importOriginal) => {
|
|
5
|
+
const actual = await importOriginal<typeof import('../../lib/preview-link.js')>()
|
|
6
|
+
return { ...actual, createPreviewToken }
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const { openPublicSiteTab } = await import('../../lib/open-public-site-tab.js')
|
|
10
|
+
const { NO_PUBLIC_SITE_URL_REASON } = await import('../../lib/view-site-chrome.js')
|
|
11
|
+
|
|
12
|
+
function mockTab() {
|
|
13
|
+
return { close: vi.fn(), location: { replace: vi.fn() }, opener: {} as unknown }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals()
|
|
18
|
+
createPreviewToken.mockReset()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const base = {
|
|
22
|
+
siteUrl: 'https://example.com',
|
|
23
|
+
collection: 'pages',
|
|
24
|
+
documentId: 'p1',
|
|
25
|
+
slug: 'about',
|
|
26
|
+
path: '/about',
|
|
27
|
+
dirty: false,
|
|
28
|
+
saveDraft: vi.fn().mockResolvedValue(true),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('openPublicSiteTab', () => {
|
|
32
|
+
it('refuses to open the admin host when siteUrl is empty', async () => {
|
|
33
|
+
const open = vi.fn()
|
|
34
|
+
vi.stubGlobal('window', { open })
|
|
35
|
+
await expect(openPublicSiteTab({ ...base, siteUrl: '', intent: 'view-live' })).rejects.toThrow(
|
|
36
|
+
NO_PUBLIC_SITE_URL_REASON,
|
|
37
|
+
)
|
|
38
|
+
expect(open).not.toHaveBeenCalled()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('opens the live URL with no token', async () => {
|
|
42
|
+
const open = vi.fn()
|
|
43
|
+
vi.stubGlobal('window', { open })
|
|
44
|
+
await openPublicSiteTab({ ...base, intent: 'view-live' })
|
|
45
|
+
expect(open).toHaveBeenCalledWith('https://example.com/about', '_blank', 'noopener')
|
|
46
|
+
expect(createPreviewToken).not.toHaveBeenCalled()
|
|
47
|
+
expect(base.saveDraft).not.toHaveBeenCalled()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('saves a dirty draft, mints a token, and replaces about:blank', async () => {
|
|
51
|
+
const tab = mockTab()
|
|
52
|
+
const open = vi.fn().mockReturnValue(tab)
|
|
53
|
+
vi.stubGlobal('window', { open })
|
|
54
|
+
createPreviewToken.mockResolvedValue({ token: 'tok', expiresAt: 'x' })
|
|
55
|
+
await openPublicSiteTab({ ...base, intent: 'preview-draft', dirty: true })
|
|
56
|
+
expect(open).toHaveBeenCalledWith('about:blank', '_blank')
|
|
57
|
+
expect(base.saveDraft).toHaveBeenCalledOnce()
|
|
58
|
+
expect(createPreviewToken).toHaveBeenCalledWith('pages', 'p1')
|
|
59
|
+
expect(tab.location.replace).toHaveBeenCalledWith('https://example.com/about?preview=tok')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('closes the blank tab when save fails', async () => {
|
|
63
|
+
const tab = mockTab()
|
|
64
|
+
vi.stubGlobal('window', { open: vi.fn().mockReturnValue(tab) })
|
|
65
|
+
await openPublicSiteTab({
|
|
66
|
+
...base,
|
|
67
|
+
intent: 'preview-draft',
|
|
68
|
+
dirty: true,
|
|
69
|
+
saveDraft: vi.fn().mockResolvedValue(false),
|
|
70
|
+
})
|
|
71
|
+
expect(tab.close).toHaveBeenCalledOnce()
|
|
72
|
+
expect(createPreviewToken).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { NO_PUBLIC_SITE_URL_REASON, resolveViewSiteChrome } from '../../lib/view-site-chrome.js'
|
|
3
|
+
|
|
4
|
+
const base = {
|
|
5
|
+
dirty: false,
|
|
6
|
+
hasPublicSiteUrl: true,
|
|
7
|
+
hasIdentity: true,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('resolveViewSiteChrome', () => {
|
|
11
|
+
it('previews a draft and shows Share', () => {
|
|
12
|
+
expect(resolveViewSiteChrome({ ...base, status: 'DRAFT' })).toEqual({
|
|
13
|
+
viewEnabled: true,
|
|
14
|
+
primaryIntent: 'preview-draft',
|
|
15
|
+
showViewLive: false,
|
|
16
|
+
shareVisible: true,
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('treats scheduled and archived like a draft', () => {
|
|
21
|
+
for (const status of ['SCHEDULED', 'ARCHIVED'] as const) {
|
|
22
|
+
expect(resolveViewSiteChrome({ ...base, status }).primaryIntent).toBe('preview-draft')
|
|
23
|
+
expect(resolveViewSiteChrome({ ...base, status }).shareVisible).toBe(true)
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('opens live on a clean published document and hides Share', () => {
|
|
28
|
+
expect(resolveViewSiteChrome({ ...base, status: 'PUBLISHED' })).toEqual({
|
|
29
|
+
viewEnabled: true,
|
|
30
|
+
primaryIntent: 'view-live',
|
|
31
|
+
showViewLive: false,
|
|
32
|
+
shareVisible: false,
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('previews unpublished changes when published and dirty, and keeps View live + Share', () => {
|
|
37
|
+
expect(resolveViewSiteChrome({ ...base, status: 'PUBLISHED', dirty: true })).toEqual({
|
|
38
|
+
viewEnabled: true,
|
|
39
|
+
primaryIntent: 'preview-draft',
|
|
40
|
+
showViewLive: true,
|
|
41
|
+
shareVisible: true,
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('disables View site and Share when the public site URL is missing', () => {
|
|
46
|
+
const chrome = resolveViewSiteChrome({
|
|
47
|
+
...base,
|
|
48
|
+
status: 'DRAFT',
|
|
49
|
+
hasPublicSiteUrl: false,
|
|
50
|
+
})
|
|
51
|
+
expect(chrome.viewEnabled).toBe(false)
|
|
52
|
+
expect(chrome.viewDisabledReason).toBe(NO_PUBLIC_SITE_URL_REASON)
|
|
53
|
+
expect(chrome.shareVisible).toBe(true)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('hides View site and Share when the document is unsaved', () => {
|
|
57
|
+
const chrome = resolveViewSiteChrome({
|
|
58
|
+
...base,
|
|
59
|
+
status: 'DRAFT',
|
|
60
|
+
hasIdentity: false,
|
|
61
|
+
})
|
|
62
|
+
expect(chrome.viewEnabled).toBe(false)
|
|
63
|
+
expect(chrome.shareVisible).toBe(false)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -71,4 +71,9 @@ describe('ADMIN_STATIC_ROUTE_PATTERNS stays in sync with AdminRoot', () => {
|
|
|
71
71
|
const missing = [...new Set(literals)].filter((p) => !known.has(p))
|
|
72
72
|
expect(missing).toEqual([])
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
it('does not keep the retired admin-renderer preview routes', () => {
|
|
76
|
+
expect(ADMIN_STATIC_ROUTE_PATTERNS).not.toContain('/pages/:id/preview')
|
|
77
|
+
expect(ADMIN_STATIC_ROUTE_PATTERNS).not.toContain('/posts/:type/:id/preview')
|
|
78
|
+
})
|
|
74
79
|
})
|
|
@@ -58,23 +58,4 @@ describe('canvas surface is shared, not copied', () => {
|
|
|
58
58
|
expect(read(rel), `${rel} re-implements the scale`).not.toMatch(/fitScale/)
|
|
59
59
|
}
|
|
60
60
|
})
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* `SitePreviewFrame` was the third copy of `clientWidth - 48` and the one
|
|
64
|
-
* followup 2 left alone, because it scales an IFRAME rather than a rendered
|
|
65
|
-
* surface and cannot render `CanvasSurface`. It can still measure the same
|
|
66
|
-
* way, and now does: the padding arithmetic is gone even though the
|
|
67
|
-
* component is not shared.
|
|
68
|
-
*
|
|
69
|
-
* Kept apart from CANVASES on purpose — the assertion here is about the
|
|
70
|
-
* measurement, not about rendering the shared surface, and folding it in
|
|
71
|
-
* would make the other two tests claim something untrue about it.
|
|
72
|
-
*/
|
|
73
|
-
it('the site preview measures from a sentinel too, though it cannot share the surface', () => {
|
|
74
|
-
const preview = read('src/views/page-editor/SitePreviewFrame.tsx')
|
|
75
|
-
expect(preview, 'SitePreviewFrame still does padding arithmetic').not.toMatch(
|
|
76
|
-
/clientWidth\s*-\s*\d/,
|
|
77
|
-
)
|
|
78
|
-
expect(preview, 'SitePreviewFrame does not measure from a sentinel').toMatch(/sentinelRef/)
|
|
79
|
-
})
|
|
80
61
|
})
|
|
@@ -15,7 +15,6 @@ const baseProps = {
|
|
|
15
15
|
viewport: 'wide' as const,
|
|
16
16
|
onViewport: vi.fn(),
|
|
17
17
|
onBack: vi.fn(),
|
|
18
|
-
onPreview: vi.fn(),
|
|
19
18
|
onOpenSettings: vi.fn(),
|
|
20
19
|
onSaveDraft: vi.fn(),
|
|
21
20
|
onPublish: vi.fn(),
|
|
@@ -88,25 +87,93 @@ describe('EditorTopBar publish gating', () => {
|
|
|
88
87
|
})
|
|
89
88
|
})
|
|
90
89
|
|
|
91
|
-
describe('EditorTopBar view
|
|
92
|
-
it('
|
|
93
|
-
render(
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
describe('EditorTopBar view site / share', () => {
|
|
91
|
+
it('does not render the eye or the globe', () => {
|
|
92
|
+
render(
|
|
93
|
+
<EditorTopBar {...baseProps} onViewOnSite={vi.fn()} onShare={vi.fn()} hasPublicSiteUrl />,
|
|
94
|
+
)
|
|
95
|
+
expect(screen.queryByRole('button', { name: /Preview page|Preview structure/ })).toBeNull()
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
-
it('labels
|
|
98
|
+
it('labels View site on a draft and calls with preview-draft', () => {
|
|
99
99
|
const onViewOnSite = vi.fn()
|
|
100
|
-
render(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
render(
|
|
101
|
+
<EditorTopBar
|
|
102
|
+
{...baseProps}
|
|
103
|
+
status="DRAFT"
|
|
104
|
+
onViewOnSite={onViewOnSite}
|
|
105
|
+
onShare={vi.fn()}
|
|
106
|
+
hasPublicSiteUrl
|
|
107
|
+
/>,
|
|
108
|
+
)
|
|
109
|
+
fireEvent.click(screen.getByRole('button', { name: 'View site' }))
|
|
110
|
+
expect(onViewOnSite).toHaveBeenCalledWith('preview-draft')
|
|
111
|
+
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('labels View live on a clean published document and hides Share', () => {
|
|
115
|
+
const onViewOnSite = vi.fn()
|
|
116
|
+
render(
|
|
117
|
+
<EditorTopBar
|
|
118
|
+
{...baseProps}
|
|
119
|
+
status="PUBLISHED"
|
|
120
|
+
onViewOnSite={onViewOnSite}
|
|
121
|
+
onShare={vi.fn()}
|
|
122
|
+
hasPublicSiteUrl
|
|
123
|
+
/>,
|
|
124
|
+
)
|
|
125
|
+
fireEvent.click(screen.getByRole('button', { name: 'View live' }))
|
|
126
|
+
expect(onViewOnSite).toHaveBeenCalledWith('view-live')
|
|
127
|
+
expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('keeps View live as a second action when published and dirty', () => {
|
|
131
|
+
const onViewOnSite = vi.fn()
|
|
132
|
+
render(
|
|
133
|
+
<EditorTopBar
|
|
134
|
+
{...baseProps}
|
|
135
|
+
status="PUBLISHED"
|
|
136
|
+
dirty
|
|
137
|
+
onViewOnSite={onViewOnSite}
|
|
138
|
+
onShare={vi.fn()}
|
|
139
|
+
hasPublicSiteUrl
|
|
140
|
+
/>,
|
|
141
|
+
)
|
|
142
|
+
fireEvent.click(screen.getByRole('button', { name: 'View site' }))
|
|
143
|
+
expect(onViewOnSite).toHaveBeenCalledWith('preview-draft')
|
|
144
|
+
// Radix DropdownMenu opens on pointerdown (not click), which happy-dom
|
|
145
|
+
// doesn't synthesize from fireEvent.click — open via keyboard instead.
|
|
146
|
+
fireEvent.keyDown(screen.getByRole('button', { name: 'More view options' }), {
|
|
147
|
+
key: 'Enter',
|
|
148
|
+
})
|
|
149
|
+
fireEvent.click(screen.getByRole('menuitem', { name: 'View live' }))
|
|
150
|
+
expect(onViewOnSite).toHaveBeenCalledWith('view-live')
|
|
151
|
+
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('disables View site and Share when the public site URL is missing', () => {
|
|
155
|
+
render(
|
|
156
|
+
<EditorTopBar
|
|
157
|
+
{...baseProps}
|
|
158
|
+
onViewOnSite={vi.fn()}
|
|
159
|
+
onShare={vi.fn()}
|
|
160
|
+
hasPublicSiteUrl={false}
|
|
161
|
+
/>,
|
|
162
|
+
)
|
|
163
|
+
const view = screen.getByRole('button', { name: 'View site' }) as HTMLButtonElement
|
|
164
|
+
const share = screen.getByRole('button', { name: 'Share' }) as HTMLButtonElement
|
|
165
|
+
expect(view.disabled).toBe(true)
|
|
166
|
+
expect(share.disabled).toBe(true)
|
|
167
|
+
expect(view.getAttribute('title')).toBe(
|
|
168
|
+
'Set the public site URL in Settings → SEO to open the live design.',
|
|
169
|
+
)
|
|
105
170
|
})
|
|
106
171
|
|
|
107
|
-
it('hides
|
|
172
|
+
it('hides View site and Share when onViewOnSite is omitted (unsaved doc)', () => {
|
|
108
173
|
render(<EditorTopBar {...baseProps} status="PUBLISHED" />)
|
|
109
|
-
expect(screen.queryByRole('button', { name:
|
|
174
|
+
expect(screen.queryByRole('button', { name: 'View site' })).toBeNull()
|
|
175
|
+
expect(screen.queryByRole('button', { name: 'View live' })).toBeNull()
|
|
176
|
+
expect(screen.queryByRole('button', { name: 'Share' })).toBeNull()
|
|
110
177
|
})
|
|
111
178
|
})
|
|
112
179
|
|
|
@@ -133,19 +200,15 @@ describe('EditorTopBar history button', () => {
|
|
|
133
200
|
render(
|
|
134
201
|
<EditorTopBar
|
|
135
202
|
{...baseProps}
|
|
203
|
+
status="PUBLISHED"
|
|
204
|
+
dirty
|
|
205
|
+
hasPublicSiteUrl
|
|
136
206
|
onHistory={vi.fn()}
|
|
137
207
|
onViewOnSite={vi.fn()}
|
|
138
|
-
onToggleSitePreview={vi.fn()}
|
|
139
208
|
/>,
|
|
140
209
|
)
|
|
141
210
|
|
|
142
|
-
for (const name of [
|
|
143
|
-
'Page settings',
|
|
144
|
-
'Version history',
|
|
145
|
-
'Preview page',
|
|
146
|
-
'Inline site preview',
|
|
147
|
-
'View draft on site',
|
|
148
|
-
]) {
|
|
211
|
+
for (const name of ['Page settings', 'Version history', 'More view options']) {
|
|
149
212
|
const button = screen.getByRole('button', { name })
|
|
150
213
|
expect(button.className, `${name} has no touch target`).toContain('prenta-touch-target')
|
|
151
214
|
}
|
|
@@ -3,11 +3,20 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
const fetchPageForEditor = vi.fn()
|
|
6
|
+
const savePageDraft = vi.fn(async (page: unknown) => page)
|
|
6
7
|
vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
|
|
7
8
|
const actual = await importOriginal<typeof import('../../lib/page-editor-service.js')>()
|
|
8
9
|
return {
|
|
9
10
|
...actual,
|
|
10
|
-
fetchPageForEditor
|
|
11
|
+
fetchPageForEditor,
|
|
12
|
+
savePageDraft,
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
vi.mock('../../lib/seo-service.js', async (importOriginal) => {
|
|
16
|
+
const actual = await importOriginal<typeof import('../../lib/seo-service.js')>()
|
|
17
|
+
return {
|
|
18
|
+
...actual,
|
|
19
|
+
updateSeoFields: vi.fn(async () => ({})),
|
|
11
20
|
}
|
|
12
21
|
})
|
|
13
22
|
|
|
@@ -32,6 +41,7 @@ const validConfig = {
|
|
|
32
41
|
|
|
33
42
|
afterEach(() => {
|
|
34
43
|
fetchPageForEditor.mockReset()
|
|
44
|
+
savePageDraft.mockClear()
|
|
35
45
|
})
|
|
36
46
|
|
|
37
47
|
describe('PageSectionEditor schema guard', () => {
|
|
@@ -112,9 +122,8 @@ describe('PageSectionEditor structure mode', () => {
|
|
|
112
122
|
|
|
113
123
|
await screen.findByText('Sections')
|
|
114
124
|
expect(screen.queryByText(/Structure view/)).toBeNull()
|
|
115
|
-
|
|
116
|
-
expect(screen.getByRole('button', { name: '
|
|
117
|
-
expect(screen.getByRole('button', { name: 'Preview page' })).toBeTruthy()
|
|
125
|
+
expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
|
|
126
|
+
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
|
|
118
127
|
})
|
|
119
128
|
|
|
120
129
|
it('shows the structure notice and promotes "View on site" for custom section types', async () => {
|
|
@@ -130,10 +139,7 @@ describe('PageSectionEditor structure mode', () => {
|
|
|
130
139
|
|
|
131
140
|
await screen.findByText('Sections')
|
|
132
141
|
expect(screen.getByText(/Structure view/)).toBeTruthy()
|
|
133
|
-
|
|
134
|
-
expect(screen.getByRole('button', { name: /View on site/ })).toBeTruthy()
|
|
135
|
-
// Eye preview is relabeled to set expectations.
|
|
136
|
-
expect(screen.getByRole('button', { name: /Preview structure/ })).toBeTruthy()
|
|
142
|
+
expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
|
|
137
143
|
})
|
|
138
144
|
|
|
139
145
|
it('stays out of structure mode when a custom renderer is registered for the type', async () => {
|
|
@@ -161,7 +167,7 @@ describe('PageSectionEditor structure mode', () => {
|
|
|
161
167
|
// structural for this page.
|
|
162
168
|
expect(screen.getByTestId('opal-canvas')).toBeTruthy()
|
|
163
169
|
expect(screen.queryByText(/Structure view/)).toBeNull()
|
|
164
|
-
expect(screen.getByRole('button', { name: 'View
|
|
170
|
+
expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
|
|
165
171
|
})
|
|
166
172
|
|
|
167
173
|
it('exposes a version history button for saved pages', async () => {
|
|
@@ -233,3 +239,30 @@ describe('PageSectionEditor section disclosure', () => {
|
|
|
233
239
|
expect(screen.getByRole('button', { name: 'Close Hero' })).toBeTruthy()
|
|
234
240
|
})
|
|
235
241
|
})
|
|
242
|
+
|
|
243
|
+
describe('PageSectionEditor Share', () => {
|
|
244
|
+
const configWithSite = { ...validConfig, seo: { siteUrl: 'https://example.com' } }
|
|
245
|
+
|
|
246
|
+
it('saves a dirty draft before opening Share', async () => {
|
|
247
|
+
const page = loadedPage([{ id: 's1', sectionType: 'hero', name: 'Hero' }])
|
|
248
|
+
fetchPageForEditor.mockResolvedValue(page)
|
|
249
|
+
savePageDraft.mockResolvedValue(page)
|
|
250
|
+
|
|
251
|
+
render(
|
|
252
|
+
<PageSectionEditor
|
|
253
|
+
documentId="p1"
|
|
254
|
+
config={configWithSite}
|
|
255
|
+
onNavigate={() => {}}
|
|
256
|
+
session={{}}
|
|
257
|
+
/>,
|
|
258
|
+
)
|
|
259
|
+
await screen.findByText('Sections')
|
|
260
|
+
|
|
261
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit Hero' }))
|
|
262
|
+
fireEvent.change(await screen.findByLabelText('Section name'), { target: { value: 'Intro' } })
|
|
263
|
+
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
|
|
264
|
+
|
|
265
|
+
await waitFor(() => expect(savePageDraft).toHaveBeenCalledOnce())
|
|
266
|
+
expect(await screen.findByRole('dialog', { name: 'Share preview link' })).toBeTruthy()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -4,12 +4,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
|
4
4
|
|
|
5
5
|
const fetchPostForEditor = vi.fn()
|
|
6
6
|
const fetchTemplateForEditor = vi.fn()
|
|
7
|
+
const savePostDraft = vi.fn()
|
|
7
8
|
vi.mock('../../lib/post-editor-service.js', async (importOriginal) => {
|
|
8
9
|
const actual = await importOriginal<typeof import('../../lib/post-editor-service.js')>()
|
|
9
10
|
return {
|
|
10
11
|
...actual,
|
|
11
12
|
fetchPostForEditor: (...args: unknown[]) => fetchPostForEditor(...args),
|
|
12
13
|
fetchTemplateForEditor: (...args: unknown[]) => fetchTemplateForEditor(...args),
|
|
14
|
+
savePostDraft: (...args: unknown[]) => savePostDraft(...args),
|
|
13
15
|
}
|
|
14
16
|
})
|
|
15
17
|
|
|
@@ -62,6 +64,7 @@ function loadedPost(
|
|
|
62
64
|
afterEach(() => {
|
|
63
65
|
fetchPostForEditor.mockReset()
|
|
64
66
|
fetchTemplateForEditor.mockReset()
|
|
67
|
+
savePostDraft.mockReset()
|
|
65
68
|
})
|
|
66
69
|
|
|
67
70
|
describe('PostSectionEditor schema guard', () => {
|
|
@@ -126,6 +129,9 @@ describe('PostSectionEditor new post', () => {
|
|
|
126
129
|
)
|
|
127
130
|
await screen.findByText('Sections')
|
|
128
131
|
expect(screen.queryByRole('dialog', { name: 'Post details' })).toBeNull()
|
|
132
|
+
expect(screen.getByRole('button', { name: 'View site' })).toBeTruthy()
|
|
133
|
+
expect(screen.getByRole('button', { name: 'Share' })).toBeTruthy()
|
|
134
|
+
expect(screen.queryByRole('button', { name: /Preview page/ })).toBeNull()
|
|
129
135
|
})
|
|
130
136
|
})
|
|
131
137
|
|
|
@@ -201,6 +207,35 @@ describe('PostSectionEditor section disclosure', () => {
|
|
|
201
207
|
})
|
|
202
208
|
})
|
|
203
209
|
|
|
210
|
+
describe('PostSectionEditor Share', () => {
|
|
211
|
+
it('saves a dirty draft before opening Share', async () => {
|
|
212
|
+
const post = loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }])
|
|
213
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
214
|
+
fetchPostForEditor.mockResolvedValue(post)
|
|
215
|
+
savePostDraft.mockResolvedValue(post)
|
|
216
|
+
|
|
217
|
+
render(
|
|
218
|
+
<PostSectionEditor
|
|
219
|
+
postType="blog"
|
|
220
|
+
documentId="p1"
|
|
221
|
+
config={{ ...validConfig, seo: { siteUrl: 'https://example.com' } }}
|
|
222
|
+
onNavigate={() => {}}
|
|
223
|
+
session={{}}
|
|
224
|
+
/>,
|
|
225
|
+
)
|
|
226
|
+
await screen.findByText('Sections')
|
|
227
|
+
|
|
228
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit Quote' }))
|
|
229
|
+
fireEvent.change(await screen.findByLabelText('Section name'), {
|
|
230
|
+
target: { value: 'Pull quote' },
|
|
231
|
+
})
|
|
232
|
+
fireEvent.click(screen.getByRole('button', { name: 'Share' }))
|
|
233
|
+
|
|
234
|
+
await waitFor(() => expect(savePostDraft).toHaveBeenCalledOnce())
|
|
235
|
+
expect(await screen.findByRole('dialog', { name: 'Share preview link' })).toBeTruthy()
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
204
239
|
/**
|
|
205
240
|
* The post editor was the last screen still on the pre-handoff panel: a fixed
|
|
206
241
|
* 288px aside with no resize, no collapse and no tabs, with SEO in a
|
|
@@ -19,11 +19,12 @@ export interface BreakpointPickerProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
*
|
|
22
|
+
* Canvas-width selector for the editor header.
|
|
23
23
|
*
|
|
24
24
|
* A dropdown rather than a segmented row: six presets with names like "Tablet
|
|
25
25
|
* Landscape" do not fit a header strip, and the grouped menu is what makes the
|
|
26
|
-
* DESKTOP / TABLET / MOBILE structure legible.
|
|
26
|
+
* DESKTOP / TABLET / MOBILE structure legible. This only sizes the first-party
|
|
27
|
+
* canvas — it is not a live-site viewport.
|
|
27
28
|
*
|
|
28
29
|
* Radix owns the menu behaviour — outside-click, Escape, roving focus, and
|
|
29
30
|
* `aria-expanded` on the trigger — rather than a hand-rolled popover.
|
|
@@ -32,6 +33,7 @@ export function BreakpointPicker({ value, onChange, disabled }: BreakpointPicker
|
|
|
32
33
|
const [open, setOpen] = useState(false)
|
|
33
34
|
const active = getBreakpoint(value)
|
|
34
35
|
const ActiveIcon = breakpointIcon(active.icon)
|
|
36
|
+
const canvasWidthLabel = `Canvas width: ${active.name}, ${active.width} pixels. First-party layout only — use View site for the live design.`
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
39
|
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
|
|
@@ -39,7 +41,8 @@ export function BreakpointPicker({ value, onChange, disabled }: BreakpointPicker
|
|
|
39
41
|
<button
|
|
40
42
|
type="button"
|
|
41
43
|
disabled={disabled}
|
|
42
|
-
aria-label={
|
|
44
|
+
aria-label={canvasWidthLabel}
|
|
45
|
+
title={canvasWidthLabel}
|
|
43
46
|
className={`border-border flex h-[34px] items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-medium transition-colors disabled:opacity-50 ${
|
|
44
47
|
open
|
|
45
48
|
? 'border-brand bg-accent text-accent-foreground'
|
|
@@ -27,8 +27,8 @@ export function CanvasEditTip() {
|
|
|
27
27
|
<span className="text-foreground font-medium">Editing tip</span>
|
|
28
28
|
{' — '}
|
|
29
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">
|
|
31
|
-
|
|
30
|
+
list. Use <span className="text-foreground font-medium">View site</span> for the live
|
|
31
|
+
design.
|
|
32
32
|
</p>
|
|
33
33
|
<button
|
|
34
34
|
type="button"
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { AdminEditorConfig } from '@prenta/core'
|
|
2
2
|
|
|
3
|
-
/**
|
|
3
|
+
/**
|
|
4
|
+
* Ignored by the editors; returns the old boolean for compatibility only.
|
|
5
|
+
* The page and post editors no longer mount an inline site-preview iframe.
|
|
6
|
+
*/
|
|
4
7
|
export function resolveDefaultSitePreview(
|
|
5
8
|
editor: AdminEditorConfig | undefined,
|
|
6
9
|
kind: 'pages' | 'posts',
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { toast } from 'sonner'
|
|
2
|
+
import { buildPublicPreviewUrl, buildPublicUrl, createPreviewToken } from './preview-link.js'
|
|
3
|
+
import { NO_PUBLIC_SITE_URL_REASON, type ViewSiteIntent } from './view-site-chrome.js'
|
|
4
|
+
|
|
5
|
+
export async function openPublicSiteTab(opts: {
|
|
6
|
+
intent: ViewSiteIntent
|
|
7
|
+
siteUrl: string
|
|
8
|
+
collection: string
|
|
9
|
+
documentId: string
|
|
10
|
+
slug: string
|
|
11
|
+
path?: string | null
|
|
12
|
+
urlPrefix?: string
|
|
13
|
+
data?: Record<string, unknown> | null
|
|
14
|
+
dirty: boolean
|
|
15
|
+
saveDraft: () => Promise<boolean>
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
const siteUrl = opts.siteUrl.trim()
|
|
18
|
+
if (!siteUrl) throw new Error(NO_PUBLIC_SITE_URL_REASON)
|
|
19
|
+
|
|
20
|
+
const parts = {
|
|
21
|
+
siteUrl,
|
|
22
|
+
urlPrefix: opts.urlPrefix ?? '',
|
|
23
|
+
slug: opts.slug,
|
|
24
|
+
path: opts.path,
|
|
25
|
+
data: opts.data,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (opts.intent === 'view-live') {
|
|
29
|
+
window.open(buildPublicUrl(parts), '_blank', 'noopener')
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const tab = window.open('about:blank', '_blank')
|
|
34
|
+
if (!tab) {
|
|
35
|
+
toast.error('Enable pop-ups for this site to open the preview in a new tab.')
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (opts.dirty) {
|
|
40
|
+
const ok = await opts.saveDraft()
|
|
41
|
+
if (!ok) {
|
|
42
|
+
tab.close()
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const { token } = await createPreviewToken(opts.collection, opts.documentId)
|
|
47
|
+
try {
|
|
48
|
+
tab.opener = null
|
|
49
|
+
} catch {
|
|
50
|
+
/* same-origin guard */
|
|
51
|
+
}
|
|
52
|
+
tab.location.replace(buildPublicPreviewUrl({ ...parts, token }))
|
|
53
|
+
} catch (err) {
|
|
54
|
+
tab.close()
|
|
55
|
+
throw err
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PageStatus } from './page-editor-service.js'
|
|
2
|
+
|
|
3
|
+
export type ViewSiteIntent = 'preview-draft' | 'view-live'
|
|
4
|
+
|
|
5
|
+
export const NO_PUBLIC_SITE_URL_REASON =
|
|
6
|
+
'Set the public site URL in Settings → SEO to open the live design.'
|
|
7
|
+
|
|
8
|
+
export function resolveViewSiteChrome(input: {
|
|
9
|
+
status: PageStatus
|
|
10
|
+
dirty: boolean
|
|
11
|
+
hasPublicSiteUrl: boolean
|
|
12
|
+
hasIdentity: boolean
|
|
13
|
+
}): {
|
|
14
|
+
viewEnabled: boolean
|
|
15
|
+
viewDisabledReason?: string
|
|
16
|
+
primaryIntent: ViewSiteIntent
|
|
17
|
+
showViewLive: boolean
|
|
18
|
+
shareVisible: boolean
|
|
19
|
+
} {
|
|
20
|
+
const unpublished = input.status !== 'PUBLISHED' || input.dirty
|
|
21
|
+
const primaryIntent: ViewSiteIntent =
|
|
22
|
+
input.status === 'PUBLISHED' && !input.dirty ? 'view-live' : 'preview-draft'
|
|
23
|
+
const shareVisible = input.hasIdentity && unpublished
|
|
24
|
+
const viewEnabled = input.hasIdentity && input.hasPublicSiteUrl
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
viewEnabled,
|
|
28
|
+
viewDisabledReason:
|
|
29
|
+
input.hasIdentity && !input.hasPublicSiteUrl ? NO_PUBLIC_SITE_URL_REASON : undefined,
|
|
30
|
+
primaryIntent,
|
|
31
|
+
showViewLive: input.hasIdentity && input.status === 'PUBLISHED' && input.dirty,
|
|
32
|
+
shareVisible,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -40,14 +40,12 @@ export const ADMIN_STATIC_ROUTE_PATTERNS: readonly string[] = [
|
|
|
40
40
|
'/pages',
|
|
41
41
|
'/pages/:id',
|
|
42
42
|
'/pages/:id/edit',
|
|
43
|
-
'/pages/:id/preview',
|
|
44
43
|
'/pages/new',
|
|
45
44
|
'/pages/tags',
|
|
46
45
|
'/pages/tags/:id/edit',
|
|
47
46
|
'/pages/tags/new',
|
|
48
47
|
'/posts',
|
|
49
48
|
'/posts/:type/:id/edit',
|
|
50
|
-
'/posts/:type/:id/preview',
|
|
51
49
|
'/posts/:type/new',
|
|
52
50
|
'/posts/new',
|
|
53
51
|
'/posts/types',
|