@actuate-media/cms-admin 0.42.0 → 0.42.2
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/dist/__tests__/lib/post-editor-service.test.js +120 -1
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
- package/dist/__tests__/views/portal-container.test.d.ts +2 -0
- package/dist/__tests__/views/portal-container.test.d.ts.map +1 -0
- package/dist/__tests__/views/portal-container.test.js +42 -0
- package/dist/__tests__/views/portal-container.test.js.map +1 -0
- package/dist/__tests__/views/post-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/post-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-section-editor.test.js +96 -0
- package/dist/__tests__/views/post-section-editor.test.js.map +1 -0
- package/dist/components/LocaleSwitcher.d.ts.map +1 -1
- package/dist/components/LocaleSwitcher.js +2 -1
- package/dist/components/LocaleSwitcher.js.map +1 -1
- package/dist/components/SEOPanel.d.ts.map +1 -1
- package/dist/components/SEOPanel.js +2 -1
- package/dist/components/SEOPanel.js.map +1 -1
- package/dist/components/forms/Drawer.d.ts.map +1 -1
- package/dist/components/forms/Drawer.js +2 -1
- package/dist/components/forms/Drawer.js.map +1 -1
- package/dist/components/seo/Drawer.d.ts.map +1 -1
- package/dist/components/seo/Drawer.js +2 -1
- package/dist/components/seo/Drawer.js.map +1 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +2 -1
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +2 -1
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/collection-schema.d.ts +29 -0
- package/dist/lib/collection-schema.d.ts.map +1 -0
- package/dist/lib/collection-schema.js +54 -0
- package/dist/lib/collection-schema.js.map +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +2 -32
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/portal-container.d.ts +12 -0
- package/dist/lib/portal-container.d.ts.map +1 -0
- package/dist/lib/portal-container.js +16 -0
- package/dist/lib/portal-container.js.map +1 -0
- package/dist/lib/post-editor-service.d.ts +1 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -1
- package/dist/lib/post-editor-service.js +50 -9
- package/dist/lib/post-editor-service.js.map +1 -1
- package/dist/views/ApiKeys.d.ts.map +1 -1
- package/dist/views/ApiKeys.js +3 -2
- package/dist/views/ApiKeys.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +2 -1
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Redirects.d.ts.map +1 -1
- package/dist/views/Redirects.js +2 -1
- package/dist/views/Redirects.js.map +1 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +4 -3
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/BlockPicker.d.ts.map +1 -1
- package/dist/views/page-builder/BlockPicker.js +2 -1
- package/dist/views/page-builder/BlockPicker.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +5 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
- package/dist/views/page-editor/SectionsPanel.js +2 -1
- package/dist/views/page-editor/SectionsPanel.js.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
- package/dist/views/post-editor/PostSectionEditor.js +27 -4
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
- package/dist/views/settings/components.d.ts.map +1 -1
- package/dist/views/settings/components.js +2 -1
- package/dist/views/settings/components.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/lib/post-editor-service.test.ts +136 -0
- package/src/__tests__/views/portal-container.test.tsx +56 -0
- package/src/__tests__/views/post-section-editor.test.tsx +147 -0
- package/src/components/LocaleSwitcher.tsx +2 -1
- package/src/components/SEOPanel.tsx +2 -1
- package/src/components/forms/Drawer.tsx +2 -1
- package/src/components/seo/Drawer.tsx +2 -1
- package/src/layout/Header.tsx +2 -1
- package/src/layout/Sidebar.tsx +2 -1
- package/src/lib/collection-schema.ts +62 -0
- package/src/lib/page-editor-service.ts +3 -35
- package/src/lib/portal-container.ts +14 -0
- package/src/lib/post-editor-service.ts +54 -8
- package/src/views/ApiKeys.tsx +3 -2
- package/src/views/Pages/PagesListView.tsx +2 -1
- package/src/views/Redirects.tsx +2 -1
- package/src/views/Users.tsx +4 -3
- package/src/views/page-builder/BlockPicker.tsx +2 -1
- package/src/views/page-editor/PageSectionEditor.tsx +5 -2
- package/src/views/page-editor/SectionsPanel.tsx +2 -1
- package/src/views/post-editor/PostSectionEditor.tsx +49 -2
- package/src/views/settings/components.tsx +2 -1
|
@@ -11,7 +11,9 @@ const {
|
|
|
11
11
|
validatePost,
|
|
12
12
|
validateTemplate,
|
|
13
13
|
emptyPostFromTemplate,
|
|
14
|
+
fetchPostForEditor,
|
|
14
15
|
fetchTemplateForEditor,
|
|
16
|
+
missingPostFields,
|
|
15
17
|
savePostDraft,
|
|
16
18
|
} = await import('../../lib/post-editor-service.js')
|
|
17
19
|
|
|
@@ -89,6 +91,30 @@ describe('validatePost', () => {
|
|
|
89
91
|
expect(r.warnings.some((w) => w.includes('SEO title'))).toBe(true)
|
|
90
92
|
})
|
|
91
93
|
|
|
94
|
+
it('does not block publish on unmanaged (externally-managed) sections', () => {
|
|
95
|
+
const r = validatePost(
|
|
96
|
+
basePost({
|
|
97
|
+
seoTitle: 'T',
|
|
98
|
+
seoDescription: 'D',
|
|
99
|
+
sections: [
|
|
100
|
+
// No registered definition for `opal-hero` → validateSectionContent
|
|
101
|
+
// would report "Unknown section type"; the unmanaged flag must skip it.
|
|
102
|
+
{
|
|
103
|
+
id: 's1',
|
|
104
|
+
sectionType: 'opal-hero',
|
|
105
|
+
name: 'Opal Hero',
|
|
106
|
+
visible: true,
|
|
107
|
+
content: { heading: 'Hi' },
|
|
108
|
+
settings: {},
|
|
109
|
+
unmanaged: true,
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
}),
|
|
113
|
+
true,
|
|
114
|
+
)
|
|
115
|
+
expect(r.errors).toEqual([])
|
|
116
|
+
})
|
|
117
|
+
|
|
92
118
|
it('skips hidden sections during publish validation', () => {
|
|
93
119
|
const r = validatePost(
|
|
94
120
|
basePost({
|
|
@@ -240,6 +266,116 @@ describe('saveTemplate', () => {
|
|
|
240
266
|
})
|
|
241
267
|
})
|
|
242
268
|
|
|
269
|
+
describe('unmanaged section round-trip (data-loss regression)', () => {
|
|
270
|
+
// The old service coerced sections with the default `'drop'` strategy, so a
|
|
271
|
+
// post containing a section type unknown to the admin bundle (e.g. a custom
|
|
272
|
+
// `opal-*` type missing from config) lost those sections on open + save.
|
|
273
|
+
const OPAL_SECTION = {
|
|
274
|
+
id: 'op1',
|
|
275
|
+
sectionType: 'opal-prose-band',
|
|
276
|
+
name: 'Prose Band',
|
|
277
|
+
visible: true,
|
|
278
|
+
content: { html: '<p>kept</p>' },
|
|
279
|
+
settings: { paddingY: 'lg' as const },
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it('fetchPostForEditor preserves unknown section types as unmanaged', async () => {
|
|
283
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
284
|
+
data: {
|
|
285
|
+
id: 'p1',
|
|
286
|
+
title: 'Logan',
|
|
287
|
+
slug: 'logan',
|
|
288
|
+
status: 'PUBLISHED',
|
|
289
|
+
data: {
|
|
290
|
+
sections: [
|
|
291
|
+
OPAL_SECTION,
|
|
292
|
+
{ id: 'q1', sectionType: 'quote', name: 'Q', visible: true, content: {}, settings: {} },
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
})
|
|
297
|
+
const post = await fetchPostForEditor('locations', 'p1')
|
|
298
|
+
expect(post.sections.map((s) => s.sectionType)).toEqual(['opal-prose-band', 'quote'])
|
|
299
|
+
const opal = post.sections[0]!
|
|
300
|
+
expect(opal.unmanaged).toBe(true)
|
|
301
|
+
// content/settings survive verbatim
|
|
302
|
+
expect(opal.content).toEqual({ html: '<p>kept</p>' })
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('savePostDraft round-trips unmanaged sections, stripping the derived marker', async () => {
|
|
306
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
307
|
+
data: { id: 'p1', title: 'Logan', slug: 'logan', status: 'DRAFT', data: { sections: [] } },
|
|
308
|
+
})
|
|
309
|
+
await savePostDraft(
|
|
310
|
+
basePost({
|
|
311
|
+
postType: 'locations',
|
|
312
|
+
sections: [{ ...OPAL_SECTION, unmanaged: true }],
|
|
313
|
+
}),
|
|
314
|
+
)
|
|
315
|
+
const [, init] = cmsApiMock.mock.calls[0]!
|
|
316
|
+
const body = JSON.parse(init.body as string)
|
|
317
|
+
expect(body.sections).toHaveLength(1)
|
|
318
|
+
expect(body.sections[0].sectionType).toBe('opal-prose-band')
|
|
319
|
+
expect(body.sections[0].content).toEqual({ html: '<p>kept</p>' })
|
|
320
|
+
// The marker is derived from the registry on read — never persisted.
|
|
321
|
+
expect('unmanaged' in body.sections[0]).toBe(false)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('fetchTemplateForEditor preserves unknown section types in templates', async () => {
|
|
325
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
326
|
+
data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
|
|
327
|
+
})
|
|
328
|
+
const t = await fetchTemplateForEditor('locations')
|
|
329
|
+
expect(t.sections).toHaveLength(1)
|
|
330
|
+
expect(t.sections[0]!.unmanaged).toBe(true)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('emptyPostFromTemplate clones unmanaged template sections without throwing', async () => {
|
|
334
|
+
// `createSection` throws for unregistered types — the clone must not use it.
|
|
335
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
336
|
+
data: { postType: 'locations', docId: 'tmpl-1', header: {}, sections: [OPAL_SECTION] },
|
|
337
|
+
})
|
|
338
|
+
const post = await emptyPostFromTemplate('locations')
|
|
339
|
+
expect(post.sections).toHaveLength(1)
|
|
340
|
+
expect(post.sections[0]!.sectionType).toBe('opal-prose-band')
|
|
341
|
+
expect(post.sections[0]!.id).not.toBe('op1')
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
describe('missingPostFields (schema guard)', () => {
|
|
346
|
+
const FULL_FIELDS = {
|
|
347
|
+
title: { type: 'text' },
|
|
348
|
+
slug: { type: 'slug' },
|
|
349
|
+
sections: { type: 'json' },
|
|
350
|
+
excerpt: { type: 'text' },
|
|
351
|
+
featuredImage: { type: 'media' },
|
|
352
|
+
body: { type: 'richText' },
|
|
353
|
+
category: { type: 'text' },
|
|
354
|
+
publishDate: { type: 'date' },
|
|
355
|
+
metaTitle: { type: 'text' },
|
|
356
|
+
metaDescription: { type: 'text' },
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
it('returns [] for a fully-declared post-type collection', () => {
|
|
360
|
+
const config = { collections: { blog: { slug: 'blog', fields: FULL_FIELDS } } }
|
|
361
|
+
expect(missingPostFields(config, 'blog')).toEqual([])
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it('reports fields the editor writes that the collection does not declare', () => {
|
|
365
|
+
const config = {
|
|
366
|
+
collections: { blog: { slug: 'blog', fields: { title: { type: 'text' } } } },
|
|
367
|
+
}
|
|
368
|
+
const missing = missingPostFields(config, 'blog')
|
|
369
|
+
expect(missing).toContain('sections')
|
|
370
|
+
expect(missing).toContain('metaTitle')
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
it('trusts the consumer when config or the collection is absent', () => {
|
|
374
|
+
expect(missingPostFields(undefined, 'blog')).toEqual([])
|
|
375
|
+
expect(missingPostFields({ collections: {} }, 'blog')).toEqual([])
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
243
379
|
describe('savePostDraft', () => {
|
|
244
380
|
it('PUTs the post fields + sections for an existing post', async () => {
|
|
245
381
|
cmsApiMock.mockResolvedValueOnce({
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
import { ConfirmDangerousSettingDialog } from '../../views/settings/components.js'
|
|
5
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Regression: theme tokens only exist under `.actuate-admin` (theme.css), so
|
|
9
|
+
* Radix portals must mount INSIDE that scope. Portaling to document.body
|
|
10
|
+
* (Radix's default) produced unstyled overlays — transparent cards, colorless
|
|
11
|
+
* destructive buttons, broken dark mode.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
function renderInAdminRoot(ui: React.ReactElement) {
|
|
17
|
+
const root = document.createElement('div')
|
|
18
|
+
root.className = 'actuate-admin'
|
|
19
|
+
document.body.appendChild(root)
|
|
20
|
+
return render(ui, { container: root })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('adminPortalContainer', () => {
|
|
24
|
+
it('resolves the admin root element', () => {
|
|
25
|
+
const root = document.createElement('div')
|
|
26
|
+
root.className = 'actuate-admin'
|
|
27
|
+
document.body.appendChild(root)
|
|
28
|
+
expect(adminPortalContainer()).toBe(root)
|
|
29
|
+
root.remove()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns undefined when no admin root is mounted', () => {
|
|
33
|
+
document.querySelectorAll('.actuate-admin').forEach((el) => el.remove())
|
|
34
|
+
expect(adminPortalContainer()).toBeUndefined()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('portaled overlays mount inside the admin theme scope', () => {
|
|
39
|
+
it('renders the dangerous-setting confirm dialog within .actuate-admin', async () => {
|
|
40
|
+
renderInAdminRoot(
|
|
41
|
+
<ConfirmDangerousSettingDialog
|
|
42
|
+
open
|
|
43
|
+
title="Block this production site from search engines?"
|
|
44
|
+
description="This may prevent search engines from indexing your production site."
|
|
45
|
+
confirmLabel="Enable No Index"
|
|
46
|
+
onConfirm={() => {}}
|
|
47
|
+
onCancel={() => {}}
|
|
48
|
+
/>,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const title = await screen.findByText(/Block this production site/)
|
|
52
|
+
// The dialog content must live inside the token scope, otherwise
|
|
53
|
+
// bg-card / text-warning / bg-destructive resolve to nothing.
|
|
54
|
+
expect(title.closest('.actuate-admin')).not.toBeNull()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const fetchPostForEditor = vi.fn()
|
|
6
|
+
const fetchTemplateForEditor = vi.fn()
|
|
7
|
+
vi.mock('../../lib/post-editor-service.js', async (importOriginal) => {
|
|
8
|
+
const actual = await importOriginal<typeof import('../../lib/post-editor-service.js')>()
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
fetchPostForEditor: (...args: unknown[]) => fetchPostForEditor(...args),
|
|
12
|
+
fetchTemplateForEditor: (...args: unknown[]) => fetchTemplateForEditor(...args),
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const { PostSectionEditor } = await import('../../views/post-editor/PostSectionEditor.js')
|
|
17
|
+
|
|
18
|
+
const validConfig = {
|
|
19
|
+
collections: {
|
|
20
|
+
blog: {
|
|
21
|
+
slug: 'blog',
|
|
22
|
+
labels: { singular: 'Post', plural: 'Posts' },
|
|
23
|
+
fields: {
|
|
24
|
+
title: { type: 'text' },
|
|
25
|
+
slug: { type: 'slug' },
|
|
26
|
+
sections: { type: 'json' },
|
|
27
|
+
excerpt: { type: 'text' },
|
|
28
|
+
featuredImage: { type: 'media' },
|
|
29
|
+
body: { type: 'richText' },
|
|
30
|
+
category: { type: 'text' },
|
|
31
|
+
publishDate: { type: 'date' },
|
|
32
|
+
metaTitle: { type: 'text' },
|
|
33
|
+
metaDescription: { type: 'text' },
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} as any
|
|
38
|
+
|
|
39
|
+
function loadedPost(
|
|
40
|
+
sections: Array<{ id: string; sectionType: string; name: string; unmanaged?: boolean }>,
|
|
41
|
+
) {
|
|
42
|
+
return {
|
|
43
|
+
id: 'p1',
|
|
44
|
+
postType: 'blog',
|
|
45
|
+
title: 'Logan',
|
|
46
|
+
slug: 'logan',
|
|
47
|
+
excerpt: '',
|
|
48
|
+
featuredImage: '',
|
|
49
|
+
body: '',
|
|
50
|
+
category: '',
|
|
51
|
+
status: 'DRAFT',
|
|
52
|
+
publishDate: null,
|
|
53
|
+
sections: sections.map((s) => ({ ...s, visible: true, content: {}, settings: {} })),
|
|
54
|
+
seoTitle: '',
|
|
55
|
+
seoDescription: '',
|
|
56
|
+
publishedAt: null,
|
|
57
|
+
updatedAt: null,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
fetchPostForEditor.mockReset()
|
|
63
|
+
fetchTemplateForEditor.mockReset()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('PostSectionEditor schema guard', () => {
|
|
67
|
+
it('refuses to operate when the post-type collection is missing the sections field', async () => {
|
|
68
|
+
const badConfig = {
|
|
69
|
+
collections: { blog: { slug: 'blog', fields: { title: { type: 'text' } } } },
|
|
70
|
+
} as any
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<PostSectionEditor
|
|
74
|
+
postType="blog"
|
|
75
|
+
documentId="p1"
|
|
76
|
+
config={badConfig}
|
|
77
|
+
onNavigate={() => {}}
|
|
78
|
+
session={{}}
|
|
79
|
+
/>,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
expect(await screen.findByText(/missing required field/i)).toBeTruthy()
|
|
83
|
+
// The guard must short-circuit before any network read.
|
|
84
|
+
expect(fetchPostForEditor).not.toHaveBeenCalled()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not block when no config is provided (embedding/test contexts)', async () => {
|
|
88
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
89
|
+
fetchPostForEditor.mockResolvedValue(
|
|
90
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
91
|
+
)
|
|
92
|
+
render(
|
|
93
|
+
<PostSectionEditor
|
|
94
|
+
postType="blog"
|
|
95
|
+
documentId="p1"
|
|
96
|
+
config={undefined}
|
|
97
|
+
onNavigate={() => {}}
|
|
98
|
+
session={{}}
|
|
99
|
+
/>,
|
|
100
|
+
)
|
|
101
|
+
expect(await screen.findByText('Sections')).toBeTruthy()
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('PostSectionEditor unmanaged sections', () => {
|
|
106
|
+
it('shows the externally-managed banner when a post has unmanaged sections', async () => {
|
|
107
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
108
|
+
fetchPostForEditor.mockResolvedValue(
|
|
109
|
+
loadedPost([
|
|
110
|
+
{ id: 's1', sectionType: 'quote', name: 'Quote' },
|
|
111
|
+
{ id: 's2', sectionType: 'opal-prose-band', name: 'Prose Band', unmanaged: true },
|
|
112
|
+
]),
|
|
113
|
+
)
|
|
114
|
+
render(
|
|
115
|
+
<PostSectionEditor
|
|
116
|
+
postType="blog"
|
|
117
|
+
documentId="p1"
|
|
118
|
+
config={validConfig}
|
|
119
|
+
onNavigate={() => {}}
|
|
120
|
+
session={{}}
|
|
121
|
+
/>,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
await screen.findByText('Sections')
|
|
125
|
+
const banner = screen.getByText(/managed outside the editor/)
|
|
126
|
+
expect(banner.textContent).toContain('opal-prose-band')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('shows no banner when every section type is registered', async () => {
|
|
130
|
+
fetchTemplateForEditor.mockRejectedValue(new Error('no template'))
|
|
131
|
+
fetchPostForEditor.mockResolvedValue(
|
|
132
|
+
loadedPost([{ id: 's1', sectionType: 'quote', name: 'Quote' }]),
|
|
133
|
+
)
|
|
134
|
+
render(
|
|
135
|
+
<PostSectionEditor
|
|
136
|
+
postType="blog"
|
|
137
|
+
documentId="p1"
|
|
138
|
+
config={validConfig}
|
|
139
|
+
onNavigate={() => {}}
|
|
140
|
+
session={{}}
|
|
141
|
+
/>,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await screen.findByText('Sections')
|
|
145
|
+
expect(screen.queryByText(/managed outside the editor/)).toBeNull()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Globe, Check, ChevronDown } from 'lucide-react'
|
|
4
4
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
|
+
import { adminPortalContainer } from '../lib/portal-container.js'
|
|
5
6
|
import { useLocale } from './LocaleProvider.js'
|
|
6
7
|
|
|
7
8
|
export function LocaleSwitcher() {
|
|
@@ -26,7 +27,7 @@ export function LocaleSwitcher() {
|
|
|
26
27
|
</button>
|
|
27
28
|
</DropdownMenu.Trigger>
|
|
28
29
|
|
|
29
|
-
<DropdownMenu.Portal>
|
|
30
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
30
31
|
<DropdownMenu.Content
|
|
31
32
|
className="z-50 min-w-[160px] rounded-lg border border-[var(--border)] bg-[var(--popover)] p-1 text-[var(--popover-foreground)] shadow-lg"
|
|
32
33
|
align="end"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import * as Select from '@radix-ui/react-select'
|
|
4
|
+
import { adminPortalContainer } from '../lib/portal-container.js'
|
|
4
5
|
import { useState, useMemo } from 'react'
|
|
5
6
|
import {
|
|
6
7
|
Search,
|
|
@@ -801,7 +802,7 @@ export function SEOPanel({
|
|
|
801
802
|
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
|
802
803
|
</Select.Icon>
|
|
803
804
|
</Select.Trigger>
|
|
804
|
-
<Select.Portal>
|
|
805
|
+
<Select.Portal container={adminPortalContainer()}>
|
|
805
806
|
<Select.Content className="border-border bg-card z-50 overflow-hidden rounded-lg border shadow-md">
|
|
806
807
|
<Select.Viewport className="p-1">
|
|
807
808
|
{ROBOTS_POLICY_OPTIONS.map((option) => (
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* token-only styling. Shared by the Forms schema / entry-detail drawers.
|
|
6
6
|
*/
|
|
7
7
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
8
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
8
9
|
import { X } from 'lucide-react'
|
|
9
10
|
import type { ReactNode } from 'react'
|
|
10
11
|
import { btnGhostIcon } from './primitives.js'
|
|
@@ -31,7 +32,7 @@ export function Drawer({
|
|
|
31
32
|
}) {
|
|
32
33
|
return (
|
|
33
34
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
34
|
-
<Dialog.Portal>
|
|
35
|
+
<Dialog.Portal container={adminPortalContainer()}>
|
|
35
36
|
<Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
|
|
36
37
|
<Dialog.Content
|
|
37
38
|
{...(description ? {} : { 'aria-describedby': undefined })}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* token-only styling. Shared by the SEO editor / fix / copilot drawers.
|
|
6
6
|
*/
|
|
7
7
|
import * as Dialog from '@radix-ui/react-dialog'
|
|
8
|
+
import { adminPortalContainer } from '../../lib/portal-container.js'
|
|
8
9
|
import { X } from 'lucide-react'
|
|
9
10
|
import type { ReactNode } from 'react'
|
|
10
11
|
import { btnGhostIcon } from './primitives.js'
|
|
@@ -28,7 +29,7 @@ export function Drawer({
|
|
|
28
29
|
}) {
|
|
29
30
|
return (
|
|
30
31
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
|
31
|
-
<Dialog.Portal>
|
|
32
|
+
<Dialog.Portal container={adminPortalContainer()}>
|
|
32
33
|
<Dialog.Overlay className="bg-foreground/40 motion-safe:animate-in motion-safe:fade-in fixed inset-0 z-50" />
|
|
33
34
|
<Dialog.Content
|
|
34
35
|
// Radix auto-associates `aria-describedby` with the Dialog.Description
|
package/src/layout/Header.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useState, type ReactNode } from 'react'
|
|
4
4
|
import { Search, User, ChevronDown, Menu, Sun, Moon } from 'lucide-react'
|
|
5
5
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
6
|
+
import { adminPortalContainer } from '../lib/portal-container.js'
|
|
6
7
|
import { CommandPalette } from '../components/CommandPalette.js'
|
|
7
8
|
import { NotificationBell } from '../components/NotificationBell.js'
|
|
8
9
|
import { useTheme } from '../components/ThemeProvider.js'
|
|
@@ -132,7 +133,7 @@ export function Header({
|
|
|
132
133
|
</button>
|
|
133
134
|
</DropdownMenu.Trigger>
|
|
134
135
|
|
|
135
|
-
<DropdownMenu.Portal>
|
|
136
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
136
137
|
<DropdownMenu.Content
|
|
137
138
|
className="bg-popover text-popover-foreground border-border z-50 min-w-[200px] rounded-lg border p-1 shadow-lg"
|
|
138
139
|
align="end"
|
package/src/layout/Sidebar.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
4
4
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
|
5
|
+
import { adminPortalContainer } from '../lib/portal-container.js'
|
|
5
6
|
import { useTheme } from '../components/ThemeProvider.js'
|
|
6
7
|
import { fetchFormsSidebarCounts, type FormsSidebarCount } from '../lib/forms-service.js'
|
|
7
8
|
import { onFormsChanged } from '../lib/forms-events.js'
|
|
@@ -664,7 +665,7 @@ function CollapsedFlyout({ item, ctx }: { item: NavItem; ctx: NavRenderContext }
|
|
|
664
665
|
</DropdownMenu.Trigger>
|
|
665
666
|
</div>
|
|
666
667
|
|
|
667
|
-
<DropdownMenu.Portal>
|
|
668
|
+
<DropdownMenu.Portal container={adminPortalContainer()}>
|
|
668
669
|
<DropdownMenu.Content
|
|
669
670
|
side="right"
|
|
670
671
|
align="start"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection schema guard shared by the Page and Post editors.
|
|
3
|
+
*
|
|
4
|
+
* Both editors persist content as top-level keys on a collection document
|
|
5
|
+
* (`sections`, `metaTitle`, …). The server's field-access layer only writes
|
|
6
|
+
* keys that are *declared as fields* on the collection — any undeclared key is
|
|
7
|
+
* silently dropped on save. A collection missing e.g. the `sections` field
|
|
8
|
+
* would therefore discard all section content with no error, so the editors
|
|
9
|
+
* use this check to refuse to operate (and explain why) instead.
|
|
10
|
+
*
|
|
11
|
+
* When `config` (or its collections) is not provided — some embedding/test
|
|
12
|
+
* contexts don't pass it — we can't check, so we report nothing missing and
|
|
13
|
+
* trust the consumer.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface CollectionLike {
|
|
17
|
+
slug?: string
|
|
18
|
+
fields?: unknown
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Look up a collection by slug; `collections` may be a record or an array. */
|
|
22
|
+
export function findCollection(config: unknown, slug: string): CollectionLike | null {
|
|
23
|
+
const collections = (config as { collections?: unknown } | null | undefined)?.collections
|
|
24
|
+
if (!collections) return null
|
|
25
|
+
if (Array.isArray(collections)) {
|
|
26
|
+
return (collections as CollectionLike[]).find((c) => c?.slug === slug) ?? null
|
|
27
|
+
}
|
|
28
|
+
if (typeof collections === 'object') {
|
|
29
|
+
return (collections as Record<string, CollectionLike>)[slug] ?? null
|
|
30
|
+
}
|
|
31
|
+
return null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
|
|
35
|
+
export function declaredFieldNames(fields: unknown): Set<string> | null {
|
|
36
|
+
if (!fields) return null
|
|
37
|
+
if (Array.isArray(fields)) {
|
|
38
|
+
return new Set(
|
|
39
|
+
(fields as Array<{ name?: string }>)
|
|
40
|
+
.map((f) => f?.name)
|
|
41
|
+
.filter((n): n is string => typeof n === 'string'),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Returns the `required` field keys absent from the collection's declared
|
|
50
|
+
* fields, or `[]` when the collection / its fields can't be inspected.
|
|
51
|
+
*/
|
|
52
|
+
export function missingCollectionFields(
|
|
53
|
+
config: unknown,
|
|
54
|
+
slug: string,
|
|
55
|
+
required: readonly string[],
|
|
56
|
+
): string[] {
|
|
57
|
+
const collection = findCollection(config, slug)
|
|
58
|
+
if (!collection) return []
|
|
59
|
+
const declared = declaredFieldNames(collection.fields)
|
|
60
|
+
if (declared === null) return []
|
|
61
|
+
return required.filter((k) => !declared.has(k))
|
|
62
|
+
}
|
|
@@ -34,6 +34,8 @@ import {
|
|
|
34
34
|
type PageSection,
|
|
35
35
|
} from '@actuate-media/cms-core/page-sections'
|
|
36
36
|
|
|
37
|
+
import { missingCollectionFields } from './collection-schema.js'
|
|
38
|
+
|
|
37
39
|
export type { PageSection } from '@actuate-media/cms-core/page-sections'
|
|
38
40
|
|
|
39
41
|
// Re-export the pure section helpers so existing editor imports
|
|
@@ -151,41 +153,7 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
|
|
|
151
153
|
const REQUIRED_PAGE_FIELDS = ['sections', 'path', 'metaTitle', 'metaDescription'] as const
|
|
152
154
|
|
|
153
155
|
export function missingPageFields(config: unknown): string[] {
|
|
154
|
-
|
|
155
|
-
if (!pages) return []
|
|
156
|
-
const declared = declaredFieldNames(pages.fields)
|
|
157
|
-
if (declared === null) return []
|
|
158
|
-
return REQUIRED_PAGE_FIELDS.filter((k) => !declared.has(k))
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function findPagesCollection(config: unknown): { fields?: unknown } | null {
|
|
162
|
-
const collections = (config as { collections?: unknown } | null | undefined)?.collections
|
|
163
|
-
if (!collections) return null
|
|
164
|
-
if (Array.isArray(collections)) {
|
|
165
|
-
return (
|
|
166
|
-
(collections as Array<{ slug?: string; fields?: unknown }>).find(
|
|
167
|
-
(c) => c?.slug === PAGES_COLLECTION,
|
|
168
|
-
) ?? null
|
|
169
|
-
)
|
|
170
|
-
}
|
|
171
|
-
if (typeof collections === 'object') {
|
|
172
|
-
return (collections as Record<string, { fields?: unknown }>)[PAGES_COLLECTION] ?? null
|
|
173
|
-
}
|
|
174
|
-
return null
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
|
|
178
|
-
function declaredFieldNames(fields: unknown): Set<string> | null {
|
|
179
|
-
if (!fields) return null
|
|
180
|
-
if (Array.isArray(fields)) {
|
|
181
|
-
return new Set(
|
|
182
|
-
(fields as Array<{ name?: string }>)
|
|
183
|
-
.map((f) => f?.name)
|
|
184
|
-
.filter((n): n is string => typeof n === 'string'),
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
|
|
188
|
-
return null
|
|
156
|
+
return missingCollectionFields(config, PAGES_COLLECTION, REQUIRED_PAGE_FIELDS)
|
|
189
157
|
}
|
|
190
158
|
|
|
191
159
|
// ─── API row mapping ─────────────────────────────────────────────────
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin theme tokens are scoped under `.actuate-admin` (styles/theme.css),
|
|
3
|
+
* and dark mode / accent / density are applied as classes-attributes on that
|
|
4
|
+
* same root element. Radix portals default to `document.body`, which sits
|
|
5
|
+
* OUTSIDE that scope — portaled overlays would render with no theme tokens
|
|
6
|
+
* at all (transparent cards, colorless buttons) and ignore dark mode.
|
|
7
|
+
*
|
|
8
|
+
* Every Radix `*.Portal` in the admin must pass this as its `container`.
|
|
9
|
+
* Returns `undefined` during SSR (portals only materialize client-side).
|
|
10
|
+
*/
|
|
11
|
+
export function adminPortalContainer(): HTMLElement | undefined {
|
|
12
|
+
if (typeof document === 'undefined') return undefined
|
|
13
|
+
return document.querySelector<HTMLElement>('.actuate-admin') ?? undefined
|
|
14
|
+
}
|