@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.
Files changed (93) hide show
  1. package/dist/__tests__/lib/post-editor-service.test.js +120 -1
  2. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -1
  3. package/dist/__tests__/views/portal-container.test.d.ts +2 -0
  4. package/dist/__tests__/views/portal-container.test.d.ts.map +1 -0
  5. package/dist/__tests__/views/portal-container.test.js +42 -0
  6. package/dist/__tests__/views/portal-container.test.js.map +1 -0
  7. package/dist/__tests__/views/post-section-editor.test.d.ts +2 -0
  8. package/dist/__tests__/views/post-section-editor.test.d.ts.map +1 -0
  9. package/dist/__tests__/views/post-section-editor.test.js +96 -0
  10. package/dist/__tests__/views/post-section-editor.test.js.map +1 -0
  11. package/dist/components/LocaleSwitcher.d.ts.map +1 -1
  12. package/dist/components/LocaleSwitcher.js +2 -1
  13. package/dist/components/LocaleSwitcher.js.map +1 -1
  14. package/dist/components/SEOPanel.d.ts.map +1 -1
  15. package/dist/components/SEOPanel.js +2 -1
  16. package/dist/components/SEOPanel.js.map +1 -1
  17. package/dist/components/forms/Drawer.d.ts.map +1 -1
  18. package/dist/components/forms/Drawer.js +2 -1
  19. package/dist/components/forms/Drawer.js.map +1 -1
  20. package/dist/components/seo/Drawer.d.ts.map +1 -1
  21. package/dist/components/seo/Drawer.js +2 -1
  22. package/dist/components/seo/Drawer.js.map +1 -1
  23. package/dist/layout/Header.d.ts.map +1 -1
  24. package/dist/layout/Header.js +2 -1
  25. package/dist/layout/Header.js.map +1 -1
  26. package/dist/layout/Sidebar.d.ts.map +1 -1
  27. package/dist/layout/Sidebar.js +2 -1
  28. package/dist/layout/Sidebar.js.map +1 -1
  29. package/dist/lib/collection-schema.d.ts +29 -0
  30. package/dist/lib/collection-schema.d.ts.map +1 -0
  31. package/dist/lib/collection-schema.js +54 -0
  32. package/dist/lib/collection-schema.js.map +1 -0
  33. package/dist/lib/page-editor-service.d.ts.map +1 -1
  34. package/dist/lib/page-editor-service.js +2 -32
  35. package/dist/lib/page-editor-service.js.map +1 -1
  36. package/dist/lib/portal-container.d.ts +12 -0
  37. package/dist/lib/portal-container.d.ts.map +1 -0
  38. package/dist/lib/portal-container.js +16 -0
  39. package/dist/lib/portal-container.js.map +1 -0
  40. package/dist/lib/post-editor-service.d.ts +1 -0
  41. package/dist/lib/post-editor-service.d.ts.map +1 -1
  42. package/dist/lib/post-editor-service.js +50 -9
  43. package/dist/lib/post-editor-service.js.map +1 -1
  44. package/dist/views/ApiKeys.d.ts.map +1 -1
  45. package/dist/views/ApiKeys.js +3 -2
  46. package/dist/views/ApiKeys.js.map +1 -1
  47. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  48. package/dist/views/Pages/PagesListView.js +2 -1
  49. package/dist/views/Pages/PagesListView.js.map +1 -1
  50. package/dist/views/Redirects.d.ts.map +1 -1
  51. package/dist/views/Redirects.js +2 -1
  52. package/dist/views/Redirects.js.map +1 -1
  53. package/dist/views/Users.d.ts.map +1 -1
  54. package/dist/views/Users.js +4 -3
  55. package/dist/views/Users.js.map +1 -1
  56. package/dist/views/page-builder/BlockPicker.d.ts.map +1 -1
  57. package/dist/views/page-builder/BlockPicker.js +2 -1
  58. package/dist/views/page-builder/BlockPicker.js.map +1 -1
  59. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  60. package/dist/views/page-editor/PageSectionEditor.js +5 -2
  61. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  62. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  63. package/dist/views/page-editor/SectionsPanel.js +2 -1
  64. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  65. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  66. package/dist/views/post-editor/PostSectionEditor.js +27 -4
  67. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  68. package/dist/views/settings/components.d.ts.map +1 -1
  69. package/dist/views/settings/components.js +2 -1
  70. package/dist/views/settings/components.js.map +1 -1
  71. package/package.json +2 -2
  72. package/src/__tests__/lib/post-editor-service.test.ts +136 -0
  73. package/src/__tests__/views/portal-container.test.tsx +56 -0
  74. package/src/__tests__/views/post-section-editor.test.tsx +147 -0
  75. package/src/components/LocaleSwitcher.tsx +2 -1
  76. package/src/components/SEOPanel.tsx +2 -1
  77. package/src/components/forms/Drawer.tsx +2 -1
  78. package/src/components/seo/Drawer.tsx +2 -1
  79. package/src/layout/Header.tsx +2 -1
  80. package/src/layout/Sidebar.tsx +2 -1
  81. package/src/lib/collection-schema.ts +62 -0
  82. package/src/lib/page-editor-service.ts +3 -35
  83. package/src/lib/portal-container.ts +14 -0
  84. package/src/lib/post-editor-service.ts +54 -8
  85. package/src/views/ApiKeys.tsx +3 -2
  86. package/src/views/Pages/PagesListView.tsx +2 -1
  87. package/src/views/Redirects.tsx +2 -1
  88. package/src/views/Users.tsx +4 -3
  89. package/src/views/page-builder/BlockPicker.tsx +2 -1
  90. package/src/views/page-editor/PageSectionEditor.tsx +5 -2
  91. package/src/views/page-editor/SectionsPanel.tsx +2 -1
  92. package/src/views/post-editor/PostSectionEditor.tsx +49 -2
  93. 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
@@ -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"
@@ -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
- const pages = findPagesCollection(config)
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
+ }