@actuate-media/cms-admin 0.14.1 → 0.16.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +148 -2
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
- package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
- package/dist/__tests__/components/posts-badges.test.js +68 -0
- package/dist/__tests__/components/posts-badges.test.js.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
- package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service.test.js +137 -0
- package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service.test.js +140 -0
- package/dist/__tests__/lib/pages-service.test.js.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-editor-service.test.js +185 -0
- package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
- package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
- package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/posts-service.test.js +244 -0
- package/dist/__tests__/lib/posts-service.test.js.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/posts-list-view.test.js +98 -0
- package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/pages/tag-badges.d.ts +21 -0
- package/dist/components/pages/tag-badges.d.ts.map +1 -0
- package/dist/components/pages/tag-badges.js +56 -0
- package/dist/components/pages/tag-badges.js.map +1 -0
- package/dist/components/posts/badges.d.ts +25 -0
- package/dist/components/posts/badges.d.ts.map +1 -0
- package/dist/components/posts/badges.js +123 -0
- package/dist/components/posts/badges.js.map +1 -0
- package/dist/components/posts/icons.d.ts +12 -0
- package/dist/components/posts/icons.d.ts.map +1 -0
- package/dist/components/posts/icons.js +26 -0
- package/dist/components/posts/icons.js.map +1 -0
- package/dist/layout/Header.d.ts +14 -1
- package/dist/layout/Header.d.ts.map +1 -1
- package/dist/layout/Header.js +10 -3
- package/dist/layout/Header.js.map +1 -1
- package/dist/layout/Layout.d.ts.map +1 -1
- package/dist/layout/Layout.js +78 -2
- package/dist/layout/Layout.js.map +1 -1
- package/dist/layout/Sidebar.d.ts +16 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +176 -49
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +45 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -0
- package/dist/lib/page-editor-service.js +153 -0
- package/dist/lib/page-editor-service.js.map +1 -0
- package/dist/lib/pages-service.d.ts +143 -0
- package/dist/lib/pages-service.d.ts.map +1 -0
- package/dist/lib/pages-service.js +444 -0
- package/dist/lib/pages-service.js.map +1 -0
- package/dist/lib/post-editor-service.d.ts +57 -0
- package/dist/lib/post-editor-service.d.ts.map +1 -0
- package/dist/lib/post-editor-service.js +234 -0
- package/dist/lib/post-editor-service.js.map +1 -0
- package/dist/lib/posts-service.d.ts +73 -0
- package/dist/lib/posts-service.d.ts.map +1 -0
- package/dist/lib/posts-service.js +245 -0
- package/dist/lib/posts-service.js.map +1 -0
- package/dist/lib/preview-link.d.ts +32 -0
- package/dist/lib/preview-link.d.ts.map +1 -0
- package/dist/lib/preview-link.js +40 -0
- package/dist/lib/preview-link.js.map +1 -0
- package/dist/router/index.d.ts +1 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +9 -1
- package/dist/router/index.js.map +1 -1
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +147 -62
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/Pages/PageTagManager.d.ts +14 -0
- package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
- package/dist/views/Pages/PageTagManager.js +145 -0
- package/dist/views/Pages/PageTagManager.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts +18 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -0
- package/dist/views/Pages/PagesListView.js +287 -0
- package/dist/views/Pages/PagesListView.js.map +1 -0
- package/dist/views/Posts/NewPostPicker.d.ts +16 -0
- package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
- package/dist/views/Posts/NewPostPicker.js +59 -0
- package/dist/views/Posts/NewPostPicker.js.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
- package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
- package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
- package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +26 -0
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
- package/dist/views/Posts/PostTypesView.js +52 -0
- package/dist/views/Posts/PostTypesView.js.map +1 -0
- package/dist/views/Posts/PostsListView.d.ts +25 -0
- package/dist/views/Posts/PostsListView.d.ts.map +1 -0
- package/dist/views/Posts/PostsListView.js +239 -0
- package/dist/views/Posts/PostsListView.js.map +1 -0
- package/dist/views/Posts/post-type-presets.d.ts +32 -0
- package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
- package/dist/views/Posts/post-type-presets.js +72 -0
- package/dist/views/Posts/post-type-presets.js.map +1 -0
- package/dist/views/page-builder/Widgets.d.ts +13 -0
- package/dist/views/page-builder/Widgets.d.ts.map +1 -0
- package/dist/views/page-builder/Widgets.js +15 -0
- package/dist/views/page-builder/Widgets.js.map +1 -0
- package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
- package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
- package/dist/views/page-editor/AddSectionModal.js +22 -0
- package/dist/views/page-editor/AddSectionModal.js.map +1 -0
- package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
- package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
- package/dist/views/page-editor/EditorCanvas.js +64 -0
- package/dist/views/page-editor/EditorCanvas.js.map +1 -0
- package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
- package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
- package/dist/views/page-editor/EditorTopBar.js +42 -0
- package/dist/views/page-editor/EditorTopBar.js.map +1 -0
- package/dist/views/page-editor/PagePreview.d.ts +13 -0
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
- package/dist/views/page-editor/PagePreview.js +37 -0
- package/dist/views/page-editor/PagePreview.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.js +272 -0
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
- package/dist/views/page-editor/PageSettingsModal.js +52 -0
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
- package/dist/views/page-editor/SectionInspector.d.ts +17 -0
- package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
- package/dist/views/page-editor/SectionInspector.js +98 -0
- package/dist/views/page-editor/SectionInspector.js.map +1 -0
- package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
- package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
- package/dist/views/page-editor/SectionsPanel.js +43 -0
- package/dist/views/page-editor/SectionsPanel.js.map +1 -0
- package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
- package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
- package/dist/views/page-editor/ValidationSummary.js +10 -0
- package/dist/views/page-editor/ValidationSummary.js.map +1 -0
- package/dist/views/page-editor/section-icons.d.ts +4 -0
- package/dist/views/page-editor/section-icons.d.ts.map +1 -0
- package/dist/views/page-editor/section-icons.js +20 -0
- package/dist/views/page-editor/section-icons.js.map +1 -0
- package/dist/views/page-editor/section-types.d.ts +11 -0
- package/dist/views/page-editor/section-types.d.ts.map +1 -0
- package/dist/views/page-editor/section-types.js +10 -0
- package/dist/views/page-editor/section-types.js.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
- package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
- package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
- package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
- package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
- package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
- package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
- package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
- package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/GallerySection.js +17 -0
- package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
- package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
- package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
- package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/MissionSection.js +12 -0
- package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
- package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
- package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
- package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
- package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
- package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
- package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
- package/dist/views/page-editor/sections/index.d.ts +14 -0
- package/dist/views/page-editor/sections/index.d.ts.map +1 -0
- package/dist/views/page-editor/sections/index.js +12 -0
- package/dist/views/page-editor/sections/index.js.map +1 -0
- package/dist/views/page-editor/sections/parts.d.ts +55 -0
- package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
- package/dist/views/page-editor/sections/parts.js +60 -0
- package/dist/views/page-editor/sections/parts.js.map +1 -0
- package/dist/views/page-editor/viewports.d.ts +14 -0
- package/dist/views/page-editor/viewports.d.ts.map +1 -0
- package/dist/views/page-editor/viewports.js +12 -0
- package/dist/views/page-editor/viewports.js.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
- package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
- package/dist/views/post-editor/PostEditorCanvas.js +58 -0
- package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
- package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
- package/dist/views/post-editor/PostFieldsModal.js +53 -0
- package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
- package/dist/views/post-editor/PostHeader.d.ts +15 -0
- package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeader.js +27 -0
- package/dist/views/post-editor/PostHeader.js.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
- package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
- package/dist/views/post-editor/PostHeaderPanel.js +21 -0
- package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
- package/dist/views/post-editor/PostPreview.d.ts +14 -0
- package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
- package/dist/views/post-editor/PostPreview.js +53 -0
- package/dist/views/post-editor/PostPreview.js.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
- package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostSectionEditor.js +290 -0
- package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
- package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
- package/dist/views/post-editor/PostTemplateEditor.js +144 -0
- package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
- package/package.json +2 -2
- package/src/AdminRoot.tsx +213 -2
- package/src/__tests__/components/posts-badges.test.tsx +77 -0
- package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
- package/src/__tests__/lib/page-editor-service.test.ts +173 -0
- package/src/__tests__/lib/pages-service.test.ts +157 -0
- package/src/__tests__/lib/post-editor-service.test.ts +215 -0
- package/src/__tests__/lib/posts-service.test.ts +279 -0
- package/src/__tests__/views/posts-list-view.test.ts +110 -0
- package/src/components/pages/tag-badges.tsx +104 -0
- package/src/components/posts/badges.tsx +170 -0
- package/src/components/posts/icons.ts +40 -0
- package/src/layout/Header.tsx +90 -43
- package/src/layout/Layout.tsx +166 -1
- package/src/layout/Sidebar.tsx +287 -101
- package/src/lib/page-editor-service.ts +233 -0
- package/src/lib/pages-service.ts +599 -0
- package/src/lib/post-editor-service.ts +342 -0
- package/src/lib/posts-service.ts +366 -0
- package/src/lib/preview-link.ts +63 -0
- package/src/router/index.ts +10 -1
- package/src/views/Dashboard.tsx +270 -131
- package/src/views/Pages/PageTagManager.tsx +436 -0
- package/src/views/Pages/PagesListView.tsx +944 -0
- package/src/views/Posts/NewPostPicker.tsx +147 -0
- package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
- package/src/views/Posts/PostTypesView.tsx +194 -0
- package/src/views/Posts/PostsListView.tsx +869 -0
- package/src/views/Posts/post-type-presets.ts +105 -0
- package/src/views/page-builder/Widgets.tsx +44 -0
- package/src/views/page-editor/AddSectionModal.tsx +56 -0
- package/src/views/page-editor/EditorCanvas.tsx +127 -0
- package/src/views/page-editor/EditorTopBar.tsx +233 -0
- package/src/views/page-editor/PagePreview.tsx +96 -0
- package/src/views/page-editor/PageSectionEditor.tsx +476 -0
- package/src/views/page-editor/PageSettingsModal.tsx +154 -0
- package/src/views/page-editor/SectionInspector.tsx +599 -0
- package/src/views/page-editor/SectionsPanel.tsx +270 -0
- package/src/views/page-editor/ValidationSummary.tsx +53 -0
- package/src/views/page-editor/section-icons.ts +33 -0
- package/src/views/page-editor/section-types.ts +27 -0
- package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
- package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
- package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
- package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
- package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
- package/src/views/page-editor/sections/GallerySection.tsx +54 -0
- package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
- package/src/views/page-editor/sections/MissionSection.tsx +22 -0
- package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
- package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
- package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
- package/src/views/page-editor/sections/index.ts +13 -0
- package/src/views/page-editor/sections/parts.tsx +113 -0
- package/src/views/page-editor/viewports.ts +25 -0
- package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
- package/src/views/post-editor/PostFieldsModal.tsx +196 -0
- package/src/views/post-editor/PostHeader.tsx +87 -0
- package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
- package/src/views/post-editor/PostPreview.tsx +114 -0
- package/src/views/post-editor/PostSectionEditor.tsx +506 -0
- package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `cmsApi` is the only I/O dependency; mock it so the service is tested
|
|
4
|
+
// hermetically. Responses follow the SDK `{ data, error }` envelope.
|
|
5
|
+
const cmsApiMock = vi.fn()
|
|
6
|
+
vi.mock('../../lib/api.js', () => ({
|
|
7
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
validatePost,
|
|
12
|
+
validateTemplate,
|
|
13
|
+
emptyPostFromTemplate,
|
|
14
|
+
fetchTemplateForEditor,
|
|
15
|
+
savePostDraft,
|
|
16
|
+
} = await import('../../lib/post-editor-service.js')
|
|
17
|
+
|
|
18
|
+
type EditorPostT = Awaited<ReturnType<typeof emptyPostFromTemplate>>
|
|
19
|
+
|
|
20
|
+
function basePost(over: Partial<EditorPostT> = {}): EditorPostT {
|
|
21
|
+
return {
|
|
22
|
+
id: 'p1',
|
|
23
|
+
postType: 'blog',
|
|
24
|
+
title: 'Hello',
|
|
25
|
+
slug: 'hello',
|
|
26
|
+
excerpt: '',
|
|
27
|
+
featuredImage: '',
|
|
28
|
+
body: '',
|
|
29
|
+
category: '',
|
|
30
|
+
status: 'DRAFT',
|
|
31
|
+
publishDate: null,
|
|
32
|
+
sections: [],
|
|
33
|
+
seoTitle: '',
|
|
34
|
+
seoDescription: '',
|
|
35
|
+
publishedAt: null,
|
|
36
|
+
updatedAt: null,
|
|
37
|
+
...over,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
cmsApiMock.mockReset()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('validatePost', () => {
|
|
46
|
+
it('requires title + slug', () => {
|
|
47
|
+
const r = validatePost(basePost({ title: '', slug: '' }), false)
|
|
48
|
+
expect(r.errors).toContain('Post title is required.')
|
|
49
|
+
expect(r.errors).toContain('Post slug is required.')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not run section validation for draft saves', () => {
|
|
53
|
+
const r = validatePost(
|
|
54
|
+
basePost({
|
|
55
|
+
sections: [
|
|
56
|
+
{
|
|
57
|
+
id: 's1',
|
|
58
|
+
sectionType: 'quote',
|
|
59
|
+
name: 'Quote',
|
|
60
|
+
visible: true,
|
|
61
|
+
content: {},
|
|
62
|
+
settings: {},
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
}),
|
|
66
|
+
false,
|
|
67
|
+
)
|
|
68
|
+
expect(r.errors).toEqual([])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('runs section + SEO checks at publish time', () => {
|
|
72
|
+
const r = validatePost(
|
|
73
|
+
basePost({
|
|
74
|
+
sections: [
|
|
75
|
+
// missing required `quote` field → blocking section error
|
|
76
|
+
{
|
|
77
|
+
id: 's1',
|
|
78
|
+
sectionType: 'quote',
|
|
79
|
+
name: 'Quote',
|
|
80
|
+
visible: true,
|
|
81
|
+
content: {},
|
|
82
|
+
settings: {},
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
}),
|
|
86
|
+
true,
|
|
87
|
+
)
|
|
88
|
+
expect(r.errors.some((e) => e.startsWith('Quote:'))).toBe(true)
|
|
89
|
+
expect(r.warnings.some((w) => w.includes('SEO title'))).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('skips hidden sections during publish validation', () => {
|
|
93
|
+
const r = validatePost(
|
|
94
|
+
basePost({
|
|
95
|
+
seoTitle: 'T',
|
|
96
|
+
seoDescription: 'D',
|
|
97
|
+
sections: [
|
|
98
|
+
{
|
|
99
|
+
id: 's1',
|
|
100
|
+
sectionType: 'quote',
|
|
101
|
+
name: 'Quote',
|
|
102
|
+
visible: false,
|
|
103
|
+
content: {},
|
|
104
|
+
settings: {},
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
}),
|
|
108
|
+
true,
|
|
109
|
+
)
|
|
110
|
+
expect(r.errors).toEqual([])
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('validateTemplate', () => {
|
|
115
|
+
it('validates only visible section content', () => {
|
|
116
|
+
const r = validateTemplate({
|
|
117
|
+
docId: null,
|
|
118
|
+
postType: 'blog',
|
|
119
|
+
header: {
|
|
120
|
+
layout: 'centered',
|
|
121
|
+
showFeaturedImage: true,
|
|
122
|
+
showExcerpt: true,
|
|
123
|
+
showAuthor: true,
|
|
124
|
+
showDate: true,
|
|
125
|
+
showCategory: true,
|
|
126
|
+
showReadingTime: false,
|
|
127
|
+
},
|
|
128
|
+
sections: [
|
|
129
|
+
{ id: 's1', sectionType: 'quote', name: 'Quote', visible: true, content: {}, settings: {} },
|
|
130
|
+
],
|
|
131
|
+
})
|
|
132
|
+
expect(r.errors.some((e) => e.startsWith('Quote:'))).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('emptyPostFromTemplate', () => {
|
|
137
|
+
it('seeds sections from the type template with fresh ids', async () => {
|
|
138
|
+
// fetchTemplateForEditor lists the post-templates collection.
|
|
139
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
140
|
+
data: {
|
|
141
|
+
docs: [
|
|
142
|
+
{
|
|
143
|
+
id: 'tmpl-1',
|
|
144
|
+
slug: 'blog',
|
|
145
|
+
data: {
|
|
146
|
+
header: { layout: 'left' },
|
|
147
|
+
sections: [
|
|
148
|
+
{
|
|
149
|
+
id: 'orig',
|
|
150
|
+
sectionType: 'article-body',
|
|
151
|
+
name: 'Body',
|
|
152
|
+
visible: true,
|
|
153
|
+
content: {},
|
|
154
|
+
settings: {},
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
})
|
|
162
|
+
const post = await emptyPostFromTemplate('blog')
|
|
163
|
+
expect(post.id).toBeNull()
|
|
164
|
+
expect(post.postType).toBe('blog')
|
|
165
|
+
expect(post.sections).toHaveLength(1)
|
|
166
|
+
expect(post.sections[0]!.sectionType).toBe('article-body')
|
|
167
|
+
// cloned: a fresh id, not the template's section id
|
|
168
|
+
expect(post.sections[0]!.id).not.toBe('orig')
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('starts empty when the type has no template', async () => {
|
|
172
|
+
cmsApiMock.mockResolvedValueOnce({ data: { docs: [] } })
|
|
173
|
+
const post = await emptyPostFromTemplate('news')
|
|
174
|
+
expect(post.sections).toEqual([])
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
describe('fetchTemplateForEditor', () => {
|
|
179
|
+
it('returns an unsaved default when no template document matches', async () => {
|
|
180
|
+
cmsApiMock.mockResolvedValueOnce({ data: { docs: [] } })
|
|
181
|
+
const t = await fetchTemplateForEditor('blog')
|
|
182
|
+
expect(t.docId).toBeNull()
|
|
183
|
+
expect(t.header.layout).toBe('centered')
|
|
184
|
+
expect(t.sections).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('savePostDraft', () => {
|
|
189
|
+
it('PUTs the post fields + sections for an existing post', async () => {
|
|
190
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
191
|
+
data: { id: 'p1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: { sections: [] } },
|
|
192
|
+
})
|
|
193
|
+
await savePostDraft(basePost({ body: 'Body copy', category: 'News' }))
|
|
194
|
+
expect(cmsApiMock).toHaveBeenCalledTimes(1)
|
|
195
|
+
const [endpoint, init] = cmsApiMock.mock.calls[0]!
|
|
196
|
+
expect(endpoint).toBe('/collections/blog/p1')
|
|
197
|
+
expect(init.method).toBe('PUT')
|
|
198
|
+
const body = JSON.parse(init.body as string)
|
|
199
|
+
expect(body.title).toBe('Hello')
|
|
200
|
+
expect(body.body).toBe('Body copy')
|
|
201
|
+
expect(body.category).toBe('News')
|
|
202
|
+
expect(Array.isArray(body.sections)).toBe(true)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('POSTs to create when the post has no id', async () => {
|
|
206
|
+
cmsApiMock.mockResolvedValueOnce({
|
|
207
|
+
data: { id: 'new1', title: 'Hello', slug: 'hello', status: 'DRAFT', data: {} },
|
|
208
|
+
})
|
|
209
|
+
const out = await savePostDraft(basePost({ id: null }))
|
|
210
|
+
const [endpoint, init] = cmsApiMock.mock.calls[0]!
|
|
211
|
+
expect(endpoint).toBe('/collections/blog')
|
|
212
|
+
expect(init.method).toBe('POST')
|
|
213
|
+
expect(out.id).toBe('new1')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// `cmsApi` is the only dependency the service uses; we mock it so the
|
|
4
|
+
// tests run hermetically without an HTTP server. Each mocked response
|
|
5
|
+
// follows the SDK's `{ data, error }` envelope.
|
|
6
|
+
const cmsApiMock = vi.fn()
|
|
7
|
+
vi.mock('../../lib/api.js', () => ({
|
|
8
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
const { fetchPosts, fetchPostTypes } = await import('../../lib/posts-service.js')
|
|
12
|
+
|
|
13
|
+
interface DocFixture {
|
|
14
|
+
id: string
|
|
15
|
+
collection: string
|
|
16
|
+
title: string
|
|
17
|
+
slug: string
|
|
18
|
+
status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'
|
|
19
|
+
createdById: string
|
|
20
|
+
updatedById: string
|
|
21
|
+
publishedAt: string | null
|
|
22
|
+
scheduledAt: string | null
|
|
23
|
+
createdAt: string
|
|
24
|
+
updatedAt: string
|
|
25
|
+
data: Record<string, unknown> | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function doc(
|
|
29
|
+
o: Partial<DocFixture> & Pick<DocFixture, 'id' | 'collection' | 'title' | 'slug'>,
|
|
30
|
+
): DocFixture {
|
|
31
|
+
return {
|
|
32
|
+
status: 'PUBLISHED',
|
|
33
|
+
createdById: 'u-david',
|
|
34
|
+
updatedById: 'u-david',
|
|
35
|
+
publishedAt: '2026-05-10T00:00:00.000Z',
|
|
36
|
+
scheduledAt: null,
|
|
37
|
+
createdAt: '2026-05-10T00:00:00.000Z',
|
|
38
|
+
updatedAt: '2026-05-10T00:00:00.000Z',
|
|
39
|
+
data: { seoScore: 80 },
|
|
40
|
+
...o,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const COLLECTIONS_RESPONSE = [
|
|
45
|
+
{
|
|
46
|
+
slug: 'blog',
|
|
47
|
+
labels: { singular: 'Blog Post', plural: 'Blog' },
|
|
48
|
+
type: 'post',
|
|
49
|
+
urlPrefix: 'blog',
|
|
50
|
+
description: 'Blog posts',
|
|
51
|
+
icon: 'file-text',
|
|
52
|
+
color: 'purple',
|
|
53
|
+
group: 'Posts',
|
|
54
|
+
hidden: false,
|
|
55
|
+
fieldCount: 12,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
slug: 'case-studies',
|
|
59
|
+
labels: { singular: 'Case Study', plural: 'Case Studies' },
|
|
60
|
+
type: 'post',
|
|
61
|
+
urlPrefix: 'case-studies',
|
|
62
|
+
description: 'Case studies',
|
|
63
|
+
icon: 'briefcase',
|
|
64
|
+
color: 'green',
|
|
65
|
+
group: 'Posts',
|
|
66
|
+
hidden: false,
|
|
67
|
+
fieldCount: 13,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
slug: 'news',
|
|
71
|
+
labels: { singular: 'News Item', plural: 'News' },
|
|
72
|
+
type: 'post',
|
|
73
|
+
urlPrefix: 'news',
|
|
74
|
+
description: 'News',
|
|
75
|
+
icon: 'newspaper',
|
|
76
|
+
color: 'orange',
|
|
77
|
+
group: 'Posts',
|
|
78
|
+
hidden: false,
|
|
79
|
+
fieldCount: 11,
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
slug: 'pages',
|
|
83
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
84
|
+
type: 'page',
|
|
85
|
+
urlPrefix: '',
|
|
86
|
+
description: null,
|
|
87
|
+
icon: null,
|
|
88
|
+
color: null,
|
|
89
|
+
group: null,
|
|
90
|
+
hidden: false,
|
|
91
|
+
fieldCount: 5,
|
|
92
|
+
},
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const USERS_RESPONSE = {
|
|
96
|
+
users: [
|
|
97
|
+
{ id: 'u-sarah', name: 'Sarah Morgan', email: 'sarah@example.com' },
|
|
98
|
+
{ id: 'u-maria', name: 'Maria K', email: 'maria@example.com' },
|
|
99
|
+
{ id: 'u-david', name: 'David Carlson', email: 'david@example.com' },
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const DOCS: Record<string, DocFixture[]> = {
|
|
104
|
+
blog: [
|
|
105
|
+
doc({
|
|
106
|
+
id: 'b1',
|
|
107
|
+
collection: 'blog',
|
|
108
|
+
title: 'The Future of AI Content Marketing',
|
|
109
|
+
slug: 'ai-content-marketing',
|
|
110
|
+
createdById: 'u-maria',
|
|
111
|
+
updatedById: 'u-maria',
|
|
112
|
+
updatedAt: '2026-05-18T00:00:00.000Z',
|
|
113
|
+
data: { seoScore: 88 },
|
|
114
|
+
}),
|
|
115
|
+
doc({
|
|
116
|
+
id: 'b2',
|
|
117
|
+
collection: 'blog',
|
|
118
|
+
title: 'ActuateCMS vs Contentful',
|
|
119
|
+
slug: 'actuatecms-vs-contentful',
|
|
120
|
+
updatedAt: '2026-05-15T00:00:00.000Z',
|
|
121
|
+
data: { seoScore: 92 },
|
|
122
|
+
}),
|
|
123
|
+
doc({
|
|
124
|
+
id: 'b3',
|
|
125
|
+
collection: 'blog',
|
|
126
|
+
title: 'Mid-Year Review',
|
|
127
|
+
slug: 'mid-year-cms-trends',
|
|
128
|
+
status: 'DRAFT',
|
|
129
|
+
createdById: 'u-sarah',
|
|
130
|
+
updatedById: 'u-sarah',
|
|
131
|
+
publishedAt: null,
|
|
132
|
+
updatedAt: '2026-05-22T00:00:00.000Z',
|
|
133
|
+
data: { seoScore: null },
|
|
134
|
+
}),
|
|
135
|
+
],
|
|
136
|
+
'case-studies': [
|
|
137
|
+
doc({
|
|
138
|
+
id: 'c1',
|
|
139
|
+
collection: 'case-studies',
|
|
140
|
+
title: 'Healthcare Client SEO Results',
|
|
141
|
+
slug: 'healthcare-seo',
|
|
142
|
+
status: 'DRAFT',
|
|
143
|
+
publishedAt: null,
|
|
144
|
+
updatedAt: '2026-05-19T00:00:00.000Z',
|
|
145
|
+
data: { seoScore: 38 },
|
|
146
|
+
}),
|
|
147
|
+
],
|
|
148
|
+
news: [
|
|
149
|
+
doc({
|
|
150
|
+
id: 'n1',
|
|
151
|
+
collection: 'news',
|
|
152
|
+
title: 'Q2 2026 Product Roadmap Update',
|
|
153
|
+
slug: 'q2-roadmap',
|
|
154
|
+
status: 'SCHEDULED',
|
|
155
|
+
createdById: 'u-sarah',
|
|
156
|
+
updatedById: 'u-sarah',
|
|
157
|
+
publishedAt: null,
|
|
158
|
+
scheduledAt: '2026-06-01T00:00:00.000Z',
|
|
159
|
+
updatedAt: '2026-05-24T00:00:00.000Z',
|
|
160
|
+
data: { seoScore: 62 },
|
|
161
|
+
}),
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function installDefaultMocks() {
|
|
166
|
+
cmsApiMock.mockImplementation((url: string) => {
|
|
167
|
+
if (url === '/collections') return { data: COLLECTIONS_RESPONSE }
|
|
168
|
+
if (url.startsWith('/users')) return { data: USERS_RESPONSE }
|
|
169
|
+
const m = url.match(/^\/collections\/([^/?]+)/)
|
|
170
|
+
if (m && m[1]) {
|
|
171
|
+
const slug = decodeURIComponent(m[1])
|
|
172
|
+
return { data: { docs: DOCS[slug] ?? [], total: DOCS[slug]?.length ?? 0 } }
|
|
173
|
+
}
|
|
174
|
+
return { error: `unexpected url: ${url}` }
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('posts-service.fetchPostTypes', () => {
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
cmsApiMock.mockReset()
|
|
181
|
+
installDefaultMocks()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('returns only post-typed collections (filters out type: page)', async () => {
|
|
185
|
+
const types = await fetchPostTypes()
|
|
186
|
+
expect(types.map((t) => t.slug).sort()).toEqual(['blog', 'case-studies', 'news'])
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it('maps collection metadata onto the PostType shape', async () => {
|
|
190
|
+
const types = await fetchPostTypes()
|
|
191
|
+
const blog = types.find((t) => t.slug === 'blog')
|
|
192
|
+
expect(blog).toMatchObject({
|
|
193
|
+
id: 'blog',
|
|
194
|
+
slug: 'blog',
|
|
195
|
+
name: 'Blog',
|
|
196
|
+
singularName: 'Blog Post',
|
|
197
|
+
description: 'Blog posts',
|
|
198
|
+
urlBase: '/blog',
|
|
199
|
+
accentColor: 'purple',
|
|
200
|
+
icon: 'file-text',
|
|
201
|
+
fieldCount: 12,
|
|
202
|
+
active: true,
|
|
203
|
+
})
|
|
204
|
+
// postCount is 0 here because fetchPostTypes doesn't load docs.
|
|
205
|
+
expect(blog?.postCount).toBe(0)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
describe('posts-service.fetchPosts', () => {
|
|
210
|
+
beforeEach(() => {
|
|
211
|
+
cmsApiMock.mockReset()
|
|
212
|
+
installDefaultMocks()
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('aggregates posts across all post-typed collections', async () => {
|
|
216
|
+
const res = await fetchPosts({ pageSize: 20 })
|
|
217
|
+
expect(res.total).toBe(5)
|
|
218
|
+
expect(res.posts.map((p) => p.id).sort()).toEqual(['b1', 'b2', 'b3', 'c1', 'n1'])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('joins authors via /users', async () => {
|
|
222
|
+
const res = await fetchPosts({ pageSize: 20 })
|
|
223
|
+
const ai = res.posts.find((p) => p.id === 'b1')
|
|
224
|
+
expect(ai?.authorName).toBe('Maria K')
|
|
225
|
+
expect(ai?.authorInitials).toBe('MK')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('sorts by updated date desc by default and surfaces scheduled date for SCHEDULED posts', async () => {
|
|
229
|
+
const res = await fetchPosts({ pageSize: 20 })
|
|
230
|
+
// The SCHEDULED news post should win the "date" sort because its
|
|
231
|
+
// scheduledAt is the most recent date in the fixture set.
|
|
232
|
+
expect(res.posts[0]?.id).toBe('n1')
|
|
233
|
+
expect(res.posts[0]?.publishDate).toBe('2026-06-01T00:00:00.000Z')
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('filters by postType', async () => {
|
|
237
|
+
const res = await fetchPosts({ postType: 'blog', pageSize: 20 })
|
|
238
|
+
expect(res.total).toBe(3)
|
|
239
|
+
expect(res.posts.every((p) => p.postTypeId === 'blog')).toBe(true)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('filters by status', async () => {
|
|
243
|
+
const res = await fetchPosts({ status: 'DRAFT', pageSize: 20 })
|
|
244
|
+
expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'c1'])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('filters by author', async () => {
|
|
248
|
+
const res = await fetchPosts({ author: 'u-sarah', pageSize: 20 })
|
|
249
|
+
expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'n1'])
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('searches across title, slug, author name, and post type', async () => {
|
|
253
|
+
const res = await fetchPosts({ search: 'roadmap' })
|
|
254
|
+
expect(res.posts).toHaveLength(1)
|
|
255
|
+
expect(res.posts[0]?.id).toBe('n1')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('paginates and exposes the unfiltered tab counts', async () => {
|
|
259
|
+
const res = await fetchPosts({ pageSize: 2 })
|
|
260
|
+
expect(res.posts).toHaveLength(2)
|
|
261
|
+
expect(res.total).toBe(5)
|
|
262
|
+
const blogTab = res.types.find((t) => t.slug === 'blog')
|
|
263
|
+
expect(blogTab?.postCount).toBe(3)
|
|
264
|
+
const newsTab = res.types.find((t) => t.slug === 'news')
|
|
265
|
+
expect(newsTab?.postCount).toBe(1)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('returns only authors actually present on posts', async () => {
|
|
269
|
+
const res = await fetchPosts({ pageSize: 20 })
|
|
270
|
+
expect(res.authors.map((a) => a.id).sort()).toEqual(['u-david', 'u-maria', 'u-sarah'])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('uses the postType filter to scope the result set without touching the tab counts', async () => {
|
|
274
|
+
const res = await fetchPosts({ postType: 'news', pageSize: 20 })
|
|
275
|
+
expect(res.total).toBe(1)
|
|
276
|
+
// Tab counts still reflect the full corpus.
|
|
277
|
+
expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pageNumbers } from '../../views/Posts/PostsListView.js'
|
|
3
|
+
import { renderSnippet } from '../../views/Posts/NewPostTypeInstructions.js'
|
|
4
|
+
import { POST_TYPE_PRESETS } from '../../views/Posts/post-type-presets.js'
|
|
5
|
+
import { POST_TYPE_ICONS } from '../../components/posts/icons.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `pageNumbers` is the only pure helper exported from PostsListView.
|
|
9
|
+
* It drives the pagination footer's sliding window so the bug-prone
|
|
10
|
+
* cases (single page, exact-window boundary, ellipsis on either end)
|
|
11
|
+
* have explicit coverage.
|
|
12
|
+
*/
|
|
13
|
+
describe('pageNumbers', () => {
|
|
14
|
+
it('returns 1..N when total ≤ 7', () => {
|
|
15
|
+
expect(pageNumbers(1, 1)).toEqual([1])
|
|
16
|
+
expect(pageNumbers(3, 5)).toEqual([1, 2, 3, 4, 5])
|
|
17
|
+
expect(pageNumbers(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows leading and trailing ellipses for a mid-range page', () => {
|
|
21
|
+
expect(pageNumbers(50, 100)).toEqual([1, '…', 49, 50, 51, '…', 100])
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('hides leading ellipsis when current is near the start', () => {
|
|
25
|
+
expect(pageNumbers(2, 20)).toEqual([1, 2, 3, '…', 20])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('hides trailing ellipsis when current is near the end', () => {
|
|
29
|
+
expect(pageNumbers(19, 20)).toEqual([1, '…', 18, 19, 20])
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('renderSnippet', () => {
|
|
34
|
+
const base = {
|
|
35
|
+
slug: 'reviews',
|
|
36
|
+
singular: 'Review',
|
|
37
|
+
plural: 'Reviews',
|
|
38
|
+
color: 'purple' as const,
|
|
39
|
+
icon: 'file-text' as const,
|
|
40
|
+
description: 'Reviews',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it('emits a valid-looking collection entry with the slug, labels, and admin metadata', () => {
|
|
44
|
+
const out = renderSnippet({ ...base, extraFields: [] })
|
|
45
|
+
expect(out).toContain("'reviews': {")
|
|
46
|
+
expect(out).toContain("slug: 'reviews'")
|
|
47
|
+
expect(out).toContain("singular: 'Review'")
|
|
48
|
+
expect(out).toContain("type: 'post'")
|
|
49
|
+
expect(out).toContain("group: 'Posts'")
|
|
50
|
+
expect(out).toContain("color: 'purple'")
|
|
51
|
+
// No type-specific block when there are no extra fields.
|
|
52
|
+
expect(out).not.toContain('type-specific fields')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('injects preset extra fields after the shared base fields', () => {
|
|
56
|
+
const out = renderSnippet({
|
|
57
|
+
...base,
|
|
58
|
+
extraFields: ["client: { type: 'text', label: 'Client Name' },"],
|
|
59
|
+
})
|
|
60
|
+
expect(out).toContain('// type-specific fields')
|
|
61
|
+
expect(out).toContain("client: { type: 'text', label: 'Client Name' },")
|
|
62
|
+
// Base fields are still present alongside the injected ones.
|
|
63
|
+
expect(out).toContain("title: { type: 'text', label: 'Title', required: true },")
|
|
64
|
+
expect(out).toContain("metaDescription: { type: 'text', label: 'SEO Description' },")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('escapes single quotes in free-text fields so the snippet stays valid', () => {
|
|
68
|
+
const out = renderSnippet({
|
|
69
|
+
...base,
|
|
70
|
+
description: "Editors' picks",
|
|
71
|
+
extraFields: [],
|
|
72
|
+
})
|
|
73
|
+
expect(out).toContain("description: 'Editors\\' picks'")
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('falls back to a placeholder slug when slug is empty', () => {
|
|
77
|
+
const out = renderSnippet({ ...base, slug: '', extraFields: [] })
|
|
78
|
+
expect(out).toContain("'new-type': {")
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('POST_TYPE_PRESETS', () => {
|
|
83
|
+
it('has unique keys and slugs', () => {
|
|
84
|
+
const keys = POST_TYPE_PRESETS.map((p) => p.key)
|
|
85
|
+
const slugs = POST_TYPE_PRESETS.map((p) => p.slug)
|
|
86
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
87
|
+
expect(new Set(slugs).size).toBe(slugs.length)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('only references icons that exist in the shared icon registry', () => {
|
|
91
|
+
for (const preset of POST_TYPE_PRESETS) {
|
|
92
|
+
expect(POST_TYPE_ICONS[preset.icon]).toBeDefined()
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('uses only palette colors the badge component understands', () => {
|
|
97
|
+
const allowed = new Set(['purple', 'green', 'orange', 'blue', 'red', 'pink'])
|
|
98
|
+
for (const preset of POST_TYPE_PRESETS) {
|
|
99
|
+
expect(allowed.has(preset.color)).toBe(true)
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('every preset extra field line is a complete key: { … }, definition', () => {
|
|
104
|
+
for (const preset of POST_TYPE_PRESETS) {
|
|
105
|
+
for (const line of preset.extraFields) {
|
|
106
|
+
expect(line).toMatch(/^[a-zA-Z][\w]*: \{.*\},$/)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { paletteFor } from '../posts/badges.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tag UI for the Pages area. Two variants:
|
|
7
|
+
* - PageTagChip — interactive filter chip (filter bar). Toggles a tag
|
|
8
|
+
* on/off; announces pressed state for screen readers.
|
|
9
|
+
* - PageTagPill — static inline pill (Tags column). Compact, with a
|
|
10
|
+
* colored dot so tags don't rely on color alone.
|
|
11
|
+
*
|
|
12
|
+
* Both resolve color through the shared `paletteFor` palette so they
|
|
13
|
+
* stay consistent with Post Type badges and work in light/dark mode.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const DOT_COLOR: Record<string, string> = {
|
|
17
|
+
purple: 'bg-violet-500',
|
|
18
|
+
green: 'bg-emerald-500',
|
|
19
|
+
orange: 'bg-amber-500',
|
|
20
|
+
red: 'bg-red-500',
|
|
21
|
+
blue: 'bg-blue-500',
|
|
22
|
+
pink: 'bg-pink-500',
|
|
23
|
+
gray: 'bg-muted-foreground',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function tagDotColor(color: string | null | undefined): string {
|
|
27
|
+
if (!color) return DOT_COLOR.gray!
|
|
28
|
+
return DOT_COLOR[color] ?? DOT_COLOR.gray!
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function PageTagChip({
|
|
32
|
+
name,
|
|
33
|
+
color,
|
|
34
|
+
count,
|
|
35
|
+
selected,
|
|
36
|
+
onToggle,
|
|
37
|
+
}: {
|
|
38
|
+
name: string
|
|
39
|
+
color: string | null
|
|
40
|
+
count?: number
|
|
41
|
+
selected: boolean
|
|
42
|
+
onToggle: () => void
|
|
43
|
+
}) {
|
|
44
|
+
const p = paletteFor(color)
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={onToggle}
|
|
49
|
+
aria-pressed={selected}
|
|
50
|
+
className={`focus-visible:ring-ring inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 ${
|
|
51
|
+
selected
|
|
52
|
+
? `${p.bg} ${p.text} border-transparent ring-1 ${p.ring}`
|
|
53
|
+
: 'border-border bg-background text-foreground hover:bg-accent'
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
<span aria-hidden className={`h-2 w-2 rounded-full ${tagDotColor(color)}`} />
|
|
57
|
+
{name}
|
|
58
|
+
{typeof count === 'number' && (
|
|
59
|
+
<span className="text-muted-foreground tabular-nums">{count}</span>
|
|
60
|
+
)}
|
|
61
|
+
</button>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function PageTagPill({ name, color }: { name: string; color: string | null }) {
|
|
66
|
+
const p = paletteFor(color)
|
|
67
|
+
return (
|
|
68
|
+
<span
|
|
69
|
+
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium ${p.bg} ${p.text}`}
|
|
70
|
+
>
|
|
71
|
+
<span aria-hidden className={`h-1.5 w-1.5 rounded-full ${tagDotColor(color)}`} />
|
|
72
|
+
{name}
|
|
73
|
+
</span>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function PageTagsCell({
|
|
78
|
+
tagSlugs,
|
|
79
|
+
primaryTagId,
|
|
80
|
+
resolve,
|
|
81
|
+
}: {
|
|
82
|
+
tagSlugs: string[]
|
|
83
|
+
primaryTagId: string | null
|
|
84
|
+
resolve: (slug: string) => { name: string; color: string | null } | undefined
|
|
85
|
+
}) {
|
|
86
|
+
if (tagSlugs.length === 0) {
|
|
87
|
+
return <span className="text-muted-foreground text-xs">—</span>
|
|
88
|
+
}
|
|
89
|
+
// Show the primary tag first, then the rest, so grouping context is
|
|
90
|
+
// visible at a glance.
|
|
91
|
+
const ordered = [...tagSlugs].sort((a, b) => {
|
|
92
|
+
if (a === primaryTagId) return -1
|
|
93
|
+
if (b === primaryTagId) return 1
|
|
94
|
+
return 0
|
|
95
|
+
})
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
98
|
+
{ordered.map((slug) => {
|
|
99
|
+
const tag = resolve(slug)
|
|
100
|
+
return <PageTagPill key={slug} name={tag?.name ?? slug} color={tag?.color ?? null} />
|
|
101
|
+
})}
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|