@actuate-media/cms-admin 0.42.1 → 0.42.4
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 +1508 -0
- package/README.md +35 -0
- 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/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/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/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/lib/posts-service.d.ts.map +1 -1
- package/dist/lib/posts-service.js +3 -1
- package/dist/lib/posts-service.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/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/package.json +5 -4
- package/src/__tests__/lib/post-editor-service.test.ts +136 -0
- package/src/__tests__/views/post-section-editor.test.tsx +147 -0
- package/src/lib/collection-schema.ts +62 -0
- package/src/lib/page-editor-service.ts +3 -35
- package/src/lib/post-editor-service.ts +54 -8
- package/src/lib/posts-service.ts +4 -1
- package/src/views/page-editor/PageSectionEditor.tsx +5 -2
- package/src/views/post-editor/PostSectionEditor.tsx +49 -2
|
@@ -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,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
|
+
})
|
|
@@ -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 ─────────────────────────────────────────────────
|
|
@@ -20,8 +20,10 @@
|
|
|
20
20
|
* so the field-access layer doesn't strip them on write.
|
|
21
21
|
*/
|
|
22
22
|
import { cmsApi } from './api.js'
|
|
23
|
+
import { missingCollectionFields } from './collection-schema.js'
|
|
23
24
|
import {
|
|
24
25
|
getSectionType,
|
|
26
|
+
generateSectionId,
|
|
25
27
|
createSection,
|
|
26
28
|
addSection,
|
|
27
29
|
removeSection,
|
|
@@ -93,6 +95,30 @@ export interface ValidationResult {
|
|
|
93
95
|
warnings: string[]
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// ─── Schema guard ────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The Post Editor persists these as top-level keys on the post-type
|
|
102
|
+
* collection's documents. The server's field-access layer silently drops any
|
|
103
|
+
* key not declared as a field on the collection, so a post-type collection
|
|
104
|
+
* missing e.g. `sections` would discard all section content on save with no
|
|
105
|
+
* error. Mirrors the Page Editor's `missingPageFields` guard.
|
|
106
|
+
*/
|
|
107
|
+
const REQUIRED_POST_FIELDS = [
|
|
108
|
+
'sections',
|
|
109
|
+
'excerpt',
|
|
110
|
+
'featuredImage',
|
|
111
|
+
'body',
|
|
112
|
+
'category',
|
|
113
|
+
'publishDate',
|
|
114
|
+
'metaTitle',
|
|
115
|
+
'metaDescription',
|
|
116
|
+
] as const
|
|
117
|
+
|
|
118
|
+
export function missingPostFields(config: unknown, postType: string): string[] {
|
|
119
|
+
return missingCollectionFields(config, postType, REQUIRED_POST_FIELDS)
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
// ─── Validation ──────────────────────────────────────────────────────
|
|
97
123
|
|
|
98
124
|
function isBlank(v: unknown): boolean {
|
|
@@ -113,7 +139,9 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
113
139
|
|
|
114
140
|
if (forPublish) {
|
|
115
141
|
const visible = post.sections.filter((s) => s.visible)
|
|
116
|
-
|
|
142
|
+
// Unmanaged (externally-managed) sections have no registered field schema,
|
|
143
|
+
// so they can't be content-validated here and must not block publish.
|
|
144
|
+
for (const section of visible.filter((s) => !s.unmanaged)) {
|
|
117
145
|
const def = getSectionType(section.sectionType)
|
|
118
146
|
const label = section.name || def?.name || 'Section'
|
|
119
147
|
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
@@ -133,7 +161,7 @@ export function validatePost(post: EditorPost, forPublish = false): ValidationRe
|
|
|
133
161
|
export function validateTemplate(template: EditorPostTemplate): ValidationResult {
|
|
134
162
|
const errors: string[] = []
|
|
135
163
|
const warnings: string[] = []
|
|
136
|
-
for (const section of template.sections.filter((s) => s.visible)) {
|
|
164
|
+
for (const section of template.sections.filter((s) => s.visible && !s.unmanaged)) {
|
|
137
165
|
const def = getSectionType(section.sectionType)
|
|
138
166
|
const label = section.name || def?.name || 'Section'
|
|
139
167
|
const result = validateSectionContent(section.sectionType, section.content, section.settings)
|
|
@@ -175,7 +203,9 @@ function rowToEditorPost(postType: string, doc: DocumentApiRow): EditorPost {
|
|
|
175
203
|
category: dataStr(data, 'category'),
|
|
176
204
|
status: doc.status ?? 'DRAFT',
|
|
177
205
|
publishDate: (data.publishDate as string) ?? doc.scheduledAt ?? null,
|
|
178
|
-
|
|
206
|
+
// Preserve unknown (externally-managed) sections so the editor can show
|
|
207
|
+
// them read-only and round-trip them on save instead of dropping them.
|
|
208
|
+
sections: coerceSections(data.sections, { onUnknown: 'preserve' }),
|
|
179
209
|
seoTitle: dataStr(data, 'metaTitle'),
|
|
180
210
|
seoDescription: dataStr(data, 'metaDescription'),
|
|
181
211
|
publishedAt: doc.publishedAt ?? null,
|
|
@@ -216,7 +246,7 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
|
|
|
216
246
|
const r = res.data
|
|
217
247
|
const docId = r.docId ?? null
|
|
218
248
|
const header = coercePostHeader(r.header)
|
|
219
|
-
let sections = coerceSections(r.sections)
|
|
249
|
+
let sections = coerceSections(r.sections, { onUnknown: 'preserve' })
|
|
220
250
|
// Defend against an older server (or an empty stored template): an unsaved
|
|
221
251
|
// type should still seed the default sections so authors aren't faced with a
|
|
222
252
|
// blank canvas.
|
|
@@ -230,7 +260,10 @@ export async function fetchTemplateForEditor(postType: string): Promise<EditorPo
|
|
|
230
260
|
export async function saveTemplate(template: EditorPostTemplate): Promise<EditorPostTemplate> {
|
|
231
261
|
const res = await cmsApi<TemplateApiResponse>(templateEndpoint(template.postType), {
|
|
232
262
|
method: 'PUT',
|
|
233
|
-
body: JSON.stringify({
|
|
263
|
+
body: JSON.stringify({
|
|
264
|
+
header: template.header,
|
|
265
|
+
sections: template.sections.map(stripUnmanagedMarker),
|
|
266
|
+
}),
|
|
234
267
|
})
|
|
235
268
|
if (res.error || !res.data) throw new Error(res.error ?? 'Failed to save template')
|
|
236
269
|
const r = res.data
|
|
@@ -238,7 +271,7 @@ export async function saveTemplate(template: EditorPostTemplate): Promise<Editor
|
|
|
238
271
|
docId: r.docId ?? null,
|
|
239
272
|
postType: template.postType,
|
|
240
273
|
header: coercePostHeader(r.header),
|
|
241
|
-
sections: coerceSections(r.sections),
|
|
274
|
+
sections: coerceSections(r.sections, { onUnknown: 'preserve' }),
|
|
242
275
|
}
|
|
243
276
|
}
|
|
244
277
|
|
|
@@ -263,6 +296,15 @@ interface PostWritePayload {
|
|
|
263
296
|
metaDescription: string
|
|
264
297
|
}
|
|
265
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Strip the derived `unmanaged` marker before persisting — it is recomputed
|
|
301
|
+
* from the registry on read, so persisting it would only stale.
|
|
302
|
+
*/
|
|
303
|
+
function stripUnmanagedMarker(section: PageSection): Omit<PageSection, 'unmanaged'> {
|
|
304
|
+
const { unmanaged: _unmanaged, ...rest } = section
|
|
305
|
+
return rest
|
|
306
|
+
}
|
|
307
|
+
|
|
266
308
|
function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string {
|
|
267
309
|
const payload: PostWritePayload & Record<string, unknown> = {
|
|
268
310
|
title: post.title,
|
|
@@ -272,7 +314,9 @@ function toWriteBody(post: EditorPost, extra?: Record<string, unknown>): string
|
|
|
272
314
|
body: post.body,
|
|
273
315
|
category: post.category,
|
|
274
316
|
publishDate: post.publishDate,
|
|
275
|
-
sections
|
|
317
|
+
// Unmanaged sections are part of `post.sections`, so they round-trip on
|
|
318
|
+
// every save (no data loss).
|
|
319
|
+
sections: post.sections.map(stripUnmanagedMarker),
|
|
276
320
|
metaTitle: post.seoTitle,
|
|
277
321
|
metaDescription: post.seoDescription,
|
|
278
322
|
...extra,
|
|
@@ -318,7 +362,9 @@ export async function emptyPostFromTemplate(postType: string): Promise<EditorPos
|
|
|
318
362
|
const template = await fetchTemplateForEditor(postType)
|
|
319
363
|
// Clone with fresh ids so the post owns its own sections, decoupled from
|
|
320
364
|
// the template (editing the post never mutates the type template).
|
|
321
|
-
|
|
365
|
+
// `generateSectionId` (not `createSection`) because preserved unmanaged
|
|
366
|
+
// template sections have no registered type and `createSection` throws.
|
|
367
|
+
sections = template.sections.map((s) => ({ ...s, id: generateSectionId() }))
|
|
322
368
|
} catch {
|
|
323
369
|
// No template / fetch failure → start from an empty section list.
|
|
324
370
|
sections = []
|
package/src/lib/posts-service.ts
CHANGED
|
@@ -87,6 +87,7 @@ interface CollectionApiRow {
|
|
|
87
87
|
group: string | null
|
|
88
88
|
hidden: boolean
|
|
89
89
|
fieldCount: number
|
|
90
|
+
excludeFromPostsAggregate?: boolean
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
function isPostsCollection(c: CollectionApiRow): boolean {
|
|
@@ -96,7 +97,9 @@ function isPostsCollection(c: CollectionApiRow): boolean {
|
|
|
96
97
|
async function fetchPostCollections(): Promise<CollectionApiRow[]> {
|
|
97
98
|
const res = await cmsApi<CollectionApiRow[]>('/collections')
|
|
98
99
|
if (res.error || !res.data) return []
|
|
99
|
-
|
|
100
|
+
// `excludeFromPostsAggregate` mirrors the server-side /posts filter: a
|
|
101
|
+
// post-shaped collection with its own workflow stays out of the Posts UI.
|
|
102
|
+
return res.data.filter((c) => !c.hidden && !c.excludeFromPostsAggregate && isPostsCollection(c))
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
export async function fetchPostTypes(): Promise<PostType[]> {
|
|
@@ -58,8 +58,11 @@ interface PageSectionEditorProps {
|
|
|
58
58
|
|
|
59
59
|
function deriveCan(session: any): { canEdit: boolean; canPublish: boolean } {
|
|
60
60
|
const role: string | undefined = session?.user?.role
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// Default closed: when we can't identify a role, hide the edit/publish
|
|
62
|
+
// affordances rather than exposing them. The server independently enforces
|
|
63
|
+
// access on every write, so this is a UX guard — but it should fail safe.
|
|
64
|
+
// (Matches the Post editor's behavior.)
|
|
65
|
+
if (!role) return { canEdit: false, canPublish: false }
|
|
63
66
|
const editors = ['ADMIN', 'EDITOR', 'AUTHOR']
|
|
64
67
|
const publishers = ['ADMIN', 'EDITOR']
|
|
65
68
|
return { canEdit: editors.includes(role), canPublish: publishers.includes(role) }
|