@actuate-media/cms-admin 0.67.0 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +59 -15
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
- package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js +119 -0
- package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
- package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
- package/dist/__tests__/lib/post-types-service.test.js +88 -0
- package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
- package/dist/__tests__/router/query-string.test.d.ts +2 -0
- package/dist/__tests__/router/query-string.test.d.ts.map +1 -0
- package/dist/__tests__/router/query-string.test.js +44 -0
- package/dist/__tests__/router/query-string.test.js.map +1 -0
- package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
- package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
- package/dist/__tests__/views/forms-list.render.test.js +25 -6
- package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
- package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
- package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-templates.render.test.js +105 -0
- package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
- package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
- package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
- package/dist/__tests__/views/security-settings.render.test.js +85 -4
- package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
- package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
- package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts +2 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js +91 -0
- package/dist/__tests__/views/users-selected-deeplink.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/collections/EntryDetailPane.d.ts +10 -0
- package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
- package/dist/components/collections/EntryDetailPane.js +92 -0
- package/dist/components/collections/EntryDetailPane.js.map +1 -0
- package/dist/components/forms/FieldsPanel.js +1 -1
- package/dist/components/forms/FieldsPanel.js.map +1 -1
- package/dist/components/forms/FormSchemaPane.d.ts +8 -0
- package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
- package/dist/components/forms/FormSchemaPane.js +95 -0
- package/dist/components/forms/FormSchemaPane.js.map +1 -0
- package/dist/components/seo/SeoEditorPane.d.ts +9 -0
- package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
- package/dist/components/seo/SeoEditorPane.js +175 -0
- package/dist/components/seo/SeoEditorPane.js.map +1 -0
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/components/ui/Button.d.ts +1 -1
- package/dist/components/ui/StatusBadge.d.ts +1 -1
- package/dist/hooks/useIdleLogout.d.ts +33 -0
- package/dist/hooks/useIdleLogout.d.ts.map +1 -0
- package/dist/hooks/useIdleLogout.js +99 -0
- package/dist/hooks/useIdleLogout.js.map +1 -0
- package/dist/lib/api.d.ts +1 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +7 -1
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/post-types-service.d.ts +44 -0
- package/dist/lib/post-types-service.d.ts.map +1 -0
- package/dist/lib/post-types-service.js +83 -0
- package/dist/lib/post-types-service.js.map +1 -0
- package/dist/router/index.d.ts +7 -0
- package/dist/router/index.d.ts.map +1 -1
- package/dist/router/index.js +23 -4
- package/dist/router/index.js.map +1 -1
- package/dist/views/CollectionList.d.ts.map +1 -1
- package/dist/views/CollectionList.js +23 -4
- package/dist/views/CollectionList.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +25 -21
- package/dist/views/Forms.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts +4 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +27 -5
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
- package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
- package/dist/views/Posts/PostTypeEditor.js +159 -0
- package/dist/views/Posts/PostTypeEditor.js.map +1 -0
- package/dist/views/Posts/PostTypesView.d.ts +3 -2
- package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
- package/dist/views/Posts/PostTypesView.js +1 -1
- package/dist/views/Posts/PostTypesView.js.map +1 -1
- package/dist/views/SEO.d.ts.map +1 -1
- package/dist/views/SEO.js +2 -1
- package/dist/views/SEO.js.map +1 -1
- package/dist/views/Settings.d.ts +4 -1
- package/dist/views/Settings.d.ts.map +1 -1
- package/dist/views/Settings.js +4 -3
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/Users.d.ts +4 -1
- package/dist/views/Users.d.ts.map +1 -1
- package/dist/views/Users.js +22 -2
- package/dist/views/Users.js.map +1 -1
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
- package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
- package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
- package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
- package/dist/views/page-builder/PageBuilder.js +51 -2
- package/dist/views/page-builder/PageBuilder.js.map +1 -1
- package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
- package/dist/views/page-builder/PageTemplates.js +113 -6
- package/dist/views/page-builder/PageTemplates.js.map +1 -1
- package/dist/views/seo/ContentTab.d.ts.map +1 -1
- package/dist/views/seo/ContentTab.js +22 -6
- package/dist/views/seo/ContentTab.js.map +1 -1
- package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
- package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
- package/dist/views/settings/AccessProtectionCard.js +13 -10
- package/dist/views/settings/AccessProtectionCard.js.map +1 -1
- package/dist/views/settings/SecurityTab.d.ts.map +1 -1
- package/dist/views/settings/SecurityTab.js +5 -4
- package/dist/views/settings/SecurityTab.js.map +1 -1
- package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
- package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
- package/dist/views/settings/SessionPolicyCard.js +12 -5
- package/dist/views/settings/SessionPolicyCard.js.map +1 -1
- package/dist/views/settings/useSecuritySettings.d.ts +13 -1
- package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
- package/dist/views/settings/useSecuritySettings.js +47 -4
- package/dist/views/settings/useSecuritySettings.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +88 -18
- package/src/__tests__/hooks/useIdleLogout.test.tsx +188 -0
- package/src/__tests__/lib/post-types-service.test.ts +112 -0
- package/src/__tests__/router/query-string.test.ts +54 -0
- package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
- package/src/__tests__/views/forms-list.render.test.tsx +34 -6
- package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
- package/src/__tests__/views/page-templates.render.test.tsx +142 -0
- package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
- package/src/__tests__/views/security-settings.render.test.tsx +111 -4
- package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
- package/src/__tests__/views/users-selected-deeplink.render.test.tsx +102 -0
- package/src/components/collections/EntryDetailPane.tsx +182 -0
- package/src/components/forms/FieldsPanel.tsx +3 -1
- package/src/components/forms/FormSchemaPane.tsx +192 -0
- package/src/components/seo/SeoEditorPane.tsx +533 -0
- package/src/hooks/useIdleLogout.ts +129 -0
- package/src/lib/api.ts +8 -2
- package/src/lib/post-types-service.ts +129 -0
- package/src/router/index.ts +25 -4
- package/src/views/CollectionList.tsx +158 -110
- package/src/views/Forms.tsx +154 -126
- package/src/views/MediaBrowser.tsx +28 -5
- package/src/views/Posts/PostTypeEditor.tsx +568 -0
- package/src/views/Posts/PostTypesView.tsx +5 -4
- package/src/views/SEO.tsx +2 -1
- package/src/views/Settings.tsx +7 -2
- package/src/views/Users.tsx +23 -2
- package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
- package/src/views/page-builder/PageBuilder.tsx +69 -2
- package/src/views/page-builder/PageTemplates.tsx +340 -57
- package/src/views/seo/ContentTab.tsx +113 -80
- package/src/views/settings/AccessProtectionCard.tsx +83 -53
- package/src/views/settings/SecurityTab.tsx +25 -3
- package/src/views/settings/SessionPolicyCard.tsx +45 -9
- package/src/views/settings/useSecuritySettings.ts +78 -22
- package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
- package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
- package/dist/components/forms/FormSchemaDrawer.js +0 -99
- package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
- package/dist/components/seo/SeoEditorDrawer.js +0 -172
- package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
- package/src/components/forms/FormSchemaDrawer.tsx +0 -192
- package/src/components/seo/SeoEditorDrawer.tsx +0 -529
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { act, renderHook } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { parseRouteQuery, useAdminRouter } from '../../router/index.js'
|
|
6
|
+
|
|
7
|
+
// The admin router must carry the query string in `currentPath` so deep links
|
|
8
|
+
// like `/pages/new?parent=<id>` survive hard reloads and back/forward — not
|
|
9
|
+
// just in-app `navigate()` calls (which always carried it).
|
|
10
|
+
describe('useAdminRouter query strings', () => {
|
|
11
|
+
it('includes the query string when syncing from the browser URL on mount', () => {
|
|
12
|
+
window.history.pushState({}, '', '/admin/pages/new?parent=abc')
|
|
13
|
+
|
|
14
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/pages/new'))
|
|
15
|
+
|
|
16
|
+
expect(result.current.currentPath).toBe('/pages/new?parent=abc')
|
|
17
|
+
expect(result.current.search).toBe('?parent=abc')
|
|
18
|
+
// matchRoute strips the query, so route patterns still match.
|
|
19
|
+
expect(result.current.matchRoute('/pages/new')).toEqual({})
|
|
20
|
+
expect(parseRouteQuery(result.current.currentPath).get('parent')).toBe('abc')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('carries the query string through popstate (back/forward)', () => {
|
|
24
|
+
window.history.pushState({}, '', '/admin/settings?tab=users')
|
|
25
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/'))
|
|
26
|
+
|
|
27
|
+
act(() => {
|
|
28
|
+
window.history.pushState({}, '', '/admin/seo?tab=audit&issueId=i1')
|
|
29
|
+
window.dispatchEvent(new PopStateEvent('popstate'))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(result.current.currentPath).toBe('/seo?tab=audit&issueId=i1')
|
|
33
|
+
expect(result.current.search).toBe('?tab=audit&issueId=i1')
|
|
34
|
+
expect(result.current.matchRoute('/seo')).toEqual({})
|
|
35
|
+
expect(result.current.matchRoute('/seo/:id')).toBeNull()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles a query on the admin root path', () => {
|
|
39
|
+
window.history.pushState({}, '', '/admin?foo=1')
|
|
40
|
+
|
|
41
|
+
const { result } = renderHook(() => useAdminRouter('/admin', '/'))
|
|
42
|
+
|
|
43
|
+
expect(result.current.currentPath).toBe('/?foo=1')
|
|
44
|
+
expect(result.current.matchRoute('/')).toEqual({})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('parseRouteQuery accepts full paths, raw search strings, and hashes', () => {
|
|
48
|
+
expect(parseRouteQuery('/pages/new?parent=abc').get('parent')).toBe('abc')
|
|
49
|
+
expect(parseRouteQuery('?tab=users&selected=u1').get('selected')).toBe('u1')
|
|
50
|
+
expect(parseRouteQuery('/pages').get('parent')).toBeNull()
|
|
51
|
+
expect(parseRouteQuery('').get('parent')).toBeNull()
|
|
52
|
+
expect(parseRouteQuery('/seo?tab=audit#section').get('tab')).toBe('audit')
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -3,9 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* CollectionList row interactions with the inline entry detail pane
|
|
7
|
+
* (design Entries split-pane):
|
|
8
|
+
* - row / title click toggles the 400px detail pane (no navigation)
|
|
9
|
+
* - "Open full editor" and row double-click open the correct editor route
|
|
10
|
+
*
|
|
11
|
+
* Regression (post-list navigation bug): post-type rows must open the section
|
|
12
|
+
* editor (`/posts/:type/:id/edit`), never the generic `/:collection/:id`
|
|
13
|
+
* route — which 404s when the slug is `posts`.
|
|
9
14
|
*/
|
|
10
15
|
|
|
11
16
|
vi.mock('../../lib/api.js', () => ({
|
|
@@ -40,17 +45,58 @@ beforeEach(() => {
|
|
|
40
45
|
listDocs = []
|
|
41
46
|
})
|
|
42
47
|
|
|
43
|
-
describe('CollectionList navigation (post collection)', () => {
|
|
44
|
-
it('
|
|
48
|
+
describe('CollectionList entry detail pane + navigation (post collection)', () => {
|
|
49
|
+
it('row click opens the detail pane instead of navigating', () => {
|
|
50
|
+
listDocs = [
|
|
51
|
+
{
|
|
52
|
+
id: '1',
|
|
53
|
+
title: 'Hello',
|
|
54
|
+
status: 'PUBLISHED',
|
|
55
|
+
updatedAt: '2026-06-01T00:00:00Z',
|
|
56
|
+
data: { title: 'Hello' },
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
const onNavigate = vi.fn()
|
|
60
|
+
render(<CollectionList collectionSlug="posts" config={POST_CONFIG} onNavigate={onNavigate} />)
|
|
61
|
+
|
|
62
|
+
const titleButton = within(screen.getByRole('table')).getByRole('button', { name: 'Hello' })
|
|
63
|
+
expect(titleButton.getAttribute('aria-expanded')).toBe('false')
|
|
64
|
+
fireEvent.click(titleButton)
|
|
65
|
+
|
|
66
|
+
expect(onNavigate).not.toHaveBeenCalled()
|
|
67
|
+
expect(titleButton.getAttribute('aria-expanded')).toBe('true')
|
|
68
|
+
expect(screen.getByRole('region', { name: 'Post details' })).toBeTruthy()
|
|
69
|
+
|
|
70
|
+
// Clicking again toggles the pane closed.
|
|
71
|
+
fireEvent.click(titleButton)
|
|
72
|
+
expect(screen.queryByRole('region', { name: 'Post details' })).toBeNull()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('"Open full editor" opens the section editor, not the generic route', () => {
|
|
45
76
|
listDocs = [{ id: '1', title: 'Hello', status: 'PUBLISHED', updatedAt: '2026-06-01T00:00:00Z' }]
|
|
46
77
|
const onNavigate = vi.fn()
|
|
47
78
|
render(<CollectionList collectionSlug="posts" config={POST_CONFIG} onNavigate={onNavigate} />)
|
|
48
79
|
|
|
49
80
|
fireEvent.click(within(screen.getByRole('table')).getByRole('button', { name: 'Hello' }))
|
|
81
|
+
fireEvent.click(screen.getByRole('button', { name: 'Open full editor' }))
|
|
82
|
+
|
|
50
83
|
expect(onNavigate).toHaveBeenCalledWith('/posts/posts/1/edit')
|
|
51
84
|
expect(onNavigate).not.toHaveBeenCalledWith('/posts/1')
|
|
52
85
|
})
|
|
53
86
|
|
|
87
|
+
it('row double-click opens the section editor directly', () => {
|
|
88
|
+
listDocs = [{ id: '1', title: 'Hello', status: 'PUBLISHED', updatedAt: '2026-06-01T00:00:00Z' }]
|
|
89
|
+
const onNavigate = vi.fn()
|
|
90
|
+
render(<CollectionList collectionSlug="posts" config={POST_CONFIG} onNavigate={onNavigate} />)
|
|
91
|
+
|
|
92
|
+
const row = within(screen.getByRole('table'))
|
|
93
|
+
.getByRole('button', { name: 'Hello' })
|
|
94
|
+
.closest('tr')!
|
|
95
|
+
fireEvent.doubleClick(row)
|
|
96
|
+
|
|
97
|
+
expect(onNavigate).toHaveBeenCalledWith('/posts/posts/1/edit')
|
|
98
|
+
})
|
|
99
|
+
|
|
54
100
|
it('starts a new post in the section editor', () => {
|
|
55
101
|
const onNavigate = vi.fn()
|
|
56
102
|
render(<CollectionList collectionSlug="posts" config={POST_CONFIG} onNavigate={onNavigate} />)
|
|
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
3
3
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
4
|
|
|
5
5
|
// Drive the Forms list through a mocked forms-service so the test owns the
|
|
6
|
-
// data and can assert filtering, search, the notify toggle, and the
|
|
7
|
-
//
|
|
6
|
+
// data and can assert filtering, search, the notify toggle, and the inline
|
|
7
|
+
// schema pane without a real backend.
|
|
8
8
|
function makeForm(over: Record<string, unknown> = {}) {
|
|
9
9
|
return {
|
|
10
10
|
id: 'f1',
|
|
@@ -56,6 +56,7 @@ const fetchFormSchema = vi.fn(async () => ({
|
|
|
56
56
|
activeVersionId: null,
|
|
57
57
|
versions: [],
|
|
58
58
|
}))
|
|
59
|
+
const saveFormSchema = vi.fn(async (_id: string, _fields: unknown[]) => ({ data: {} }))
|
|
59
60
|
const fetchEmbedSettings = vi.fn(async () => ({}))
|
|
60
61
|
const fetchNotificationSettings = vi.fn(async () => ({ enabled: true, recipients: [] }))
|
|
61
62
|
|
|
@@ -64,7 +65,7 @@ vi.mock('../../lib/forms-service.js', () => ({
|
|
|
64
65
|
fetchFormStats: () => fetchFormStats(),
|
|
65
66
|
setNotifyEnabled: (id: string, v: boolean) => setNotifyEnabled(id, v),
|
|
66
67
|
fetchFormSchema: () => fetchFormSchema(),
|
|
67
|
-
saveFormSchema:
|
|
68
|
+
saveFormSchema: (id: string, fields: unknown[]) => saveFormSchema(id, fields),
|
|
68
69
|
fetchEmbedSettings: () => fetchEmbedSettings(),
|
|
69
70
|
updateEmbedSettings: vi.fn(async () => ({ data: {} })),
|
|
70
71
|
fetchNotificationSettings: () => fetchNotificationSettings(),
|
|
@@ -79,6 +80,8 @@ beforeEach(() => {
|
|
|
79
80
|
fetchForms.mockClear()
|
|
80
81
|
fetchFormStats.mockClear()
|
|
81
82
|
setNotifyEnabled.mockClear()
|
|
83
|
+
fetchFormSchema.mockClear()
|
|
84
|
+
saveFormSchema.mockClear()
|
|
82
85
|
})
|
|
83
86
|
|
|
84
87
|
describe('Forms list', () => {
|
|
@@ -128,14 +131,39 @@ describe('Forms list', () => {
|
|
|
128
131
|
await waitFor(() => expect(setNotifyEnabled).toHaveBeenCalledWith('f1', false))
|
|
129
132
|
})
|
|
130
133
|
|
|
131
|
-
it('opens the schema
|
|
134
|
+
it('opens the inline schema pane from the Schema action and saves through the service', async () => {
|
|
132
135
|
render(<Forms onNavigate={() => {}} />)
|
|
133
136
|
await screen.findByText('Contact Us')
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
const schemaButton = screen.getAllByRole('button', { name: 'Schema' })[0]!
|
|
139
|
+
expect(schemaButton.getAttribute('aria-expanded')).toBe('false')
|
|
140
|
+
fireEvent.click(schemaButton)
|
|
136
141
|
|
|
137
142
|
await waitFor(() => expect(fetchFormSchema).toHaveBeenCalled())
|
|
138
|
-
//
|
|
143
|
+
// Pane renders as a labelled inline region (not an overlay dialog).
|
|
144
|
+
expect(screen.getByRole('region', { name: 'Form schema' })).toBeTruthy()
|
|
145
|
+
expect(schemaButton.getAttribute('aria-expanded')).toBe('true')
|
|
139
146
|
expect(await screen.findByRole('tab', { name: 'Fields' })).toBeTruthy()
|
|
147
|
+
|
|
148
|
+
// Saving flows through the same saveFormSchema service call.
|
|
149
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Save Schema' }))
|
|
150
|
+
await waitFor(() =>
|
|
151
|
+
expect(saveFormSchema).toHaveBeenCalledWith(
|
|
152
|
+
'f1',
|
|
153
|
+
expect.arrayContaining([expect.objectContaining({ key: 'name' })]),
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('clicking Schema again toggles the pane closed', async () => {
|
|
159
|
+
render(<Forms onNavigate={() => {}} />)
|
|
160
|
+
await screen.findByText('Contact Us')
|
|
161
|
+
|
|
162
|
+
const schemaButton = screen.getAllByRole('button', { name: 'Schema' })[0]!
|
|
163
|
+
fireEvent.click(schemaButton)
|
|
164
|
+
await waitFor(() => expect(screen.getByRole('region', { name: 'Form schema' })).toBeTruthy())
|
|
165
|
+
|
|
166
|
+
fireEvent.click(schemaButton)
|
|
167
|
+
await waitFor(() => expect(screen.queryByRole('region', { name: 'Form schema' })).toBeNull())
|
|
140
168
|
})
|
|
141
169
|
})
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import type { PageNode } from '@actuate-media/cms-core'
|
|
5
|
+
|
|
6
|
+
const { BuilderSectionsPanel } = await import('../../views/page-builder/BuilderSectionsPanel.js')
|
|
7
|
+
|
|
8
|
+
/** Minimal builder tree: a hero section plus an empty, hidden section. */
|
|
9
|
+
function makeTree(): PageNode {
|
|
10
|
+
return {
|
|
11
|
+
id: 'page1',
|
|
12
|
+
type: 'page',
|
|
13
|
+
children: [
|
|
14
|
+
{
|
|
15
|
+
id: 'sec1',
|
|
16
|
+
type: 'section',
|
|
17
|
+
settings: {},
|
|
18
|
+
children: [
|
|
19
|
+
{
|
|
20
|
+
id: 'cont1',
|
|
21
|
+
type: 'container',
|
|
22
|
+
settings: {},
|
|
23
|
+
children: [
|
|
24
|
+
{
|
|
25
|
+
id: 'row1',
|
|
26
|
+
type: 'row',
|
|
27
|
+
settings: {},
|
|
28
|
+
children: [
|
|
29
|
+
{
|
|
30
|
+
id: 'col1',
|
|
31
|
+
type: 'column',
|
|
32
|
+
settings: { width: 12 },
|
|
33
|
+
children: [
|
|
34
|
+
{
|
|
35
|
+
id: 'blk1',
|
|
36
|
+
type: 'block',
|
|
37
|
+
settings: { blockType: 'hero', variant: 'centered' },
|
|
38
|
+
data: {},
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ id: 'sec2', type: 'section', settings: { visibility: 'hidden' }, children: [] },
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function renderPanel(over: Partial<Parameters<typeof BuilderSectionsPanel>[0]> = {}) {
|
|
54
|
+
const props = {
|
|
55
|
+
tree: makeTree(),
|
|
56
|
+
selectedNodeId: null,
|
|
57
|
+
collapsed: false,
|
|
58
|
+
onToggleCollapsed: vi.fn(),
|
|
59
|
+
onSelect: vi.fn(),
|
|
60
|
+
onReorder: vi.fn(),
|
|
61
|
+
onAddSection: vi.fn(),
|
|
62
|
+
...over,
|
|
63
|
+
}
|
|
64
|
+
render(<BuilderSectionsPanel {...props} />)
|
|
65
|
+
return props
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
vi.clearAllMocks()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('BuilderSectionsPanel', () => {
|
|
73
|
+
it('lists the page sections in order with derived names and block counts', () => {
|
|
74
|
+
renderPanel()
|
|
75
|
+
|
|
76
|
+
// First section is named after its first block's catalog label.
|
|
77
|
+
expect(screen.getByText('Hero')).toBeTruthy()
|
|
78
|
+
expect(screen.getByText('1 block')).toBeTruthy()
|
|
79
|
+
// Empty section falls back to a positional name.
|
|
80
|
+
expect(screen.getByText('Section 2')).toBeTruthy()
|
|
81
|
+
expect(screen.getByText('Empty section')).toBeTruthy()
|
|
82
|
+
// Count chip in the header.
|
|
83
|
+
expect(screen.getByText('2')).toBeTruthy()
|
|
84
|
+
// Panel is an accessible nav landmark.
|
|
85
|
+
expect(screen.getByRole('navigation', { name: 'Page sections' })).toBeTruthy()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('fires the selection callback when a section row is clicked', () => {
|
|
89
|
+
const props = renderPanel()
|
|
90
|
+
|
|
91
|
+
// `/^Hero/` excludes the drag handle ("Drag to reorder Hero").
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: /^Hero/ }))
|
|
93
|
+
expect(props.onSelect).toHaveBeenCalledWith('sec1')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('marks the selected section with aria-current', () => {
|
|
97
|
+
renderPanel({ selectedNodeId: 'sec1' })
|
|
98
|
+
|
|
99
|
+
const row = screen.getByRole('button', { name: /^Hero/ })
|
|
100
|
+
expect(row.getAttribute('aria-current')).toBe('true')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('exposes keyboard-accessible drag handles per section', () => {
|
|
104
|
+
renderPanel()
|
|
105
|
+
expect(screen.getByRole('button', { name: 'Drag to reorder Hero' })).toBeTruthy()
|
|
106
|
+
expect(screen.getByRole('button', { name: 'Drag to reorder Section 2' })).toBeTruthy()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('fires the add-section callback from the bottom affordance', () => {
|
|
110
|
+
const props = renderPanel()
|
|
111
|
+
|
|
112
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add Section' }))
|
|
113
|
+
expect(props.onAddSection).toHaveBeenCalledTimes(1)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('collapses to a rail with an expand toggle', () => {
|
|
117
|
+
const props = renderPanel({ collapsed: true })
|
|
118
|
+
|
|
119
|
+
// Section rows are gone; only the expand affordance remains.
|
|
120
|
+
expect(screen.queryByText('Hero')).toBeNull()
|
|
121
|
+
const toggle = screen.getByRole('button', { name: 'Expand sections panel' })
|
|
122
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
|
123
|
+
|
|
124
|
+
fireEvent.click(toggle)
|
|
125
|
+
expect(props.onToggleCollapsed).toHaveBeenCalledTimes(1)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('shows a collapse toggle with aria-expanded when open', () => {
|
|
129
|
+
renderPanel()
|
|
130
|
+
const toggle = screen.getByRole('button', { name: 'Collapse sections panel' })
|
|
131
|
+
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('shows an empty message when the page has no sections', () => {
|
|
135
|
+
renderPanel({ tree: { id: 'page1', type: 'page', children: [] } })
|
|
136
|
+
expect(screen.getByText(/No sections yet/)).toBeTruthy()
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const cmsApi = vi.fn()
|
|
6
|
+
vi.mock('../../lib/api.js', () => ({ cmsApi: (...args: unknown[]) => cmsApi(...args) }))
|
|
7
|
+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), loading: vi.fn() } }))
|
|
8
|
+
|
|
9
|
+
const { PageTemplates } = await import('../../views/page-builder/PageTemplates.js')
|
|
10
|
+
|
|
11
|
+
function section(id: string) {
|
|
12
|
+
return { id, type: 'section', settings: {}, children: [] }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const TEMPLATES = [
|
|
16
|
+
{
|
|
17
|
+
id: 'builtin-landing',
|
|
18
|
+
name: 'Landing Page',
|
|
19
|
+
description: 'Hero, features cards, CTA, testimonials, and final CTA',
|
|
20
|
+
category: 'landing',
|
|
21
|
+
builtIn: true,
|
|
22
|
+
updatedAt: '2026-05-29T00:00:00.000Z',
|
|
23
|
+
thumbnail: null,
|
|
24
|
+
tree: { id: 'p1', type: 'page', children: [section('a'), section('b'), section('c')] },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: 'custom-1',
|
|
28
|
+
name: 'Team Member',
|
|
29
|
+
description: 'Avatar hero, full bio, and contact links',
|
|
30
|
+
category: 'content',
|
|
31
|
+
builtIn: false,
|
|
32
|
+
updatedAt: '2026-06-10T00:00:00.000Z',
|
|
33
|
+
thumbnail: null,
|
|
34
|
+
tree: { id: 'p2', type: 'page', children: [section('a')] },
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
cmsApi.mockReset()
|
|
40
|
+
cmsApi.mockImplementation(async (endpoint: string, options?: RequestInit) => {
|
|
41
|
+
if (endpoint === '/page-templates' && !options?.method) {
|
|
42
|
+
return { data: TEMPLATES, status: 200 }
|
|
43
|
+
}
|
|
44
|
+
return { data: {}, status: 200 }
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('PageTemplates card grid', () => {
|
|
49
|
+
it('renders a card per template with name, category, and section-count meta', async () => {
|
|
50
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
51
|
+
|
|
52
|
+
expect(await screen.findByText('Landing Page')).toBeTruthy()
|
|
53
|
+
expect(screen.getByText('Team Member')).toBeTruthy()
|
|
54
|
+
// Section counts derived from each template's tree.
|
|
55
|
+
expect(screen.getByText(/3 sections/)).toBeTruthy()
|
|
56
|
+
expect(screen.getByText(/1 section ·/)).toBeTruthy()
|
|
57
|
+
// Category labels from the design's palette.
|
|
58
|
+
expect(screen.getByText('Landing Page')).toBeTruthy()
|
|
59
|
+
expect(screen.getByText('Built-in')).toBeTruthy()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('hides edit/delete actions on built-in templates (server rejects them)', async () => {
|
|
63
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
64
|
+
await screen.findByText('Landing Page')
|
|
65
|
+
|
|
66
|
+
expect(screen.queryByRole('button', { name: 'Edit Landing Page' })).toBeNull()
|
|
67
|
+
expect(screen.queryByRole('button', { name: 'Delete Landing Page' })).toBeNull()
|
|
68
|
+
expect(screen.getByRole('button', { name: 'Edit Team Member' })).toBeTruthy()
|
|
69
|
+
expect(screen.getByRole('button', { name: 'Delete Team Member' })).toBeTruthy()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('deletes a custom template only after the confirm dialog is accepted', async () => {
|
|
73
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
74
|
+
await screen.findByText('Team Member')
|
|
75
|
+
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete Team Member' }))
|
|
77
|
+
|
|
78
|
+
// Confirm dialog is up; nothing deleted yet.
|
|
79
|
+
expect(await screen.findByText('Delete template?')).toBeTruthy()
|
|
80
|
+
expect(cmsApi).not.toHaveBeenCalledWith(
|
|
81
|
+
'/page-templates/custom-1',
|
|
82
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
|
86
|
+
|
|
87
|
+
await waitFor(() =>
|
|
88
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
89
|
+
'/page-templates/custom-1',
|
|
90
|
+
expect.objectContaining({ method: 'DELETE' }),
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('opens the edit modal seeded with the template and saves via PUT', async () => {
|
|
96
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
97
|
+
await screen.findByText('Team Member')
|
|
98
|
+
|
|
99
|
+
fireEvent.click(screen.getByRole('button', { name: 'Edit Team Member' }))
|
|
100
|
+
|
|
101
|
+
const nameInput = (await screen.findByPlaceholderText('Template name')) as HTMLInputElement
|
|
102
|
+
expect(nameInput.value).toBe('Team Member')
|
|
103
|
+
|
|
104
|
+
fireEvent.change(nameInput, { target: { value: 'Team Bio' } })
|
|
105
|
+
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }))
|
|
106
|
+
|
|
107
|
+
await waitFor(() =>
|
|
108
|
+
expect(cmsApi).toHaveBeenCalledWith(
|
|
109
|
+
'/page-templates/custom-1',
|
|
110
|
+
expect.objectContaining({ method: 'PUT' }),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
const putCall = cmsApi.mock.calls.find(
|
|
114
|
+
(c) => (c[1] as RequestInit | undefined)?.method === 'PUT',
|
|
115
|
+
)!
|
|
116
|
+
expect(JSON.parse((putCall[1] as RequestInit).body as string)).toMatchObject({
|
|
117
|
+
name: 'Team Bio',
|
|
118
|
+
category: 'content',
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('filters cards by search text', async () => {
|
|
123
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
124
|
+
await screen.findByText('Team Member')
|
|
125
|
+
|
|
126
|
+
fireEvent.change(screen.getByPlaceholderText('Search templates…'), {
|
|
127
|
+
target: { value: 'landing' },
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
await waitFor(() => {
|
|
131
|
+
expect(screen.queryByText('Team Member')).toBeNull()
|
|
132
|
+
expect(screen.getByText('Landing Page')).toBeTruthy()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('shows the empty state when no templates exist', async () => {
|
|
137
|
+
cmsApi.mockResolvedValue({ data: [], status: 200 })
|
|
138
|
+
render(<PageTemplates onNavigate={() => {}} />)
|
|
139
|
+
|
|
140
|
+
expect(await screen.findByText('No page templates yet')).toBeTruthy()
|
|
141
|
+
})
|
|
142
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const cmsApiMock = vi.fn()
|
|
6
|
+
vi.mock('../../lib/api.js', () => ({
|
|
7
|
+
cmsApi: (...args: unknown[]) => cmsApiMock(...args),
|
|
8
|
+
}))
|
|
9
|
+
|
|
10
|
+
const { PostTypeEditor } = await import('../../views/Posts/PostTypeEditor.js')
|
|
11
|
+
|
|
12
|
+
const COLLECTIONS = [
|
|
13
|
+
{
|
|
14
|
+
slug: 'blog',
|
|
15
|
+
labels: { singular: 'Blog Post', plural: 'Blog' },
|
|
16
|
+
type: 'post',
|
|
17
|
+
urlPrefix: 'blog',
|
|
18
|
+
description: 'Long-form articles',
|
|
19
|
+
icon: 'file-text',
|
|
20
|
+
color: 'purple',
|
|
21
|
+
group: 'Posts',
|
|
22
|
+
hidden: false,
|
|
23
|
+
fieldCount: 3,
|
|
24
|
+
fields: [
|
|
25
|
+
{ name: 'title', type: 'text', required: true },
|
|
26
|
+
{ name: 'body', type: 'richText', required: false },
|
|
27
|
+
{ name: 'featuredImage', type: 'media', required: false },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const POSTS = {
|
|
33
|
+
posts: [],
|
|
34
|
+
total: 7,
|
|
35
|
+
page: 1,
|
|
36
|
+
pageSize: 1,
|
|
37
|
+
types: [
|
|
38
|
+
{
|
|
39
|
+
slug: 'blog',
|
|
40
|
+
singular: 'Blog Post',
|
|
41
|
+
plural: 'Blog',
|
|
42
|
+
description: 'Long-form articles',
|
|
43
|
+
urlPrefix: 'blog',
|
|
44
|
+
color: 'purple',
|
|
45
|
+
icon: 'file-text',
|
|
46
|
+
fieldCount: 3,
|
|
47
|
+
count: 7,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
authors: [],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function installMocks() {
|
|
54
|
+
cmsApiMock.mockImplementation((url: string) => {
|
|
55
|
+
if (url === '/collections') return { data: COLLECTIONS }
|
|
56
|
+
if (url.startsWith('/posts')) return { data: POSTS }
|
|
57
|
+
return { error: `unexpected url: ${url}` }
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('PostTypeEditor', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
cmsApiMock.mockReset()
|
|
64
|
+
installMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('renders identity, post-count badge, and the live field list', async () => {
|
|
68
|
+
render(<PostTypeEditor slug="blog" onNavigate={vi.fn()} />)
|
|
69
|
+
|
|
70
|
+
await waitFor(() => expect(screen.getByText('Field Builder')).toBeTruthy())
|
|
71
|
+
|
|
72
|
+
// Toolbar: breadcrumb + count badge.
|
|
73
|
+
expect(screen.getByRole('navigation', { name: 'Breadcrumb' }).textContent).toContain('Blog')
|
|
74
|
+
expect(screen.getByText('7 posts')).toBeTruthy()
|
|
75
|
+
|
|
76
|
+
// Identity panel values.
|
|
77
|
+
expect(screen.getByText('Long-form articles')).toBeTruthy()
|
|
78
|
+
expect(screen.getByText('/api/cms/collections/blog/{id}')).toBeTruthy()
|
|
79
|
+
|
|
80
|
+
// Field rows with humanized labels, keys, and type badges.
|
|
81
|
+
expect(screen.getByText('Featured Image')).toBeTruthy()
|
|
82
|
+
expect(screen.getByText('featuredImage')).toBeTruthy()
|
|
83
|
+
expect(screen.getByText('Rich Text')).toBeTruthy()
|
|
84
|
+
expect(screen.getByText('Required')).toBeTruthy()
|
|
85
|
+
expect(screen.getAllByText('Optional')).toHaveLength(2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('generates a copyable field snippet and flags duplicate keys', async () => {
|
|
89
|
+
render(<PostTypeEditor slug="blog" onNavigate={vi.fn()} />)
|
|
90
|
+
await waitFor(() => expect(screen.getByText('Field Builder')).toBeTruthy())
|
|
91
|
+
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: 'Add Field' }))
|
|
93
|
+
fireEvent.click(screen.getByRole('radio', { name: /Short Text/ }))
|
|
94
|
+
|
|
95
|
+
const nameInput = screen.getByLabelText('Field Name')
|
|
96
|
+
fireEvent.change(nameInput, { target: { value: 'Client Name' } })
|
|
97
|
+
|
|
98
|
+
// Auto-derived key + snippet preview.
|
|
99
|
+
expect((screen.getByLabelText('Field Key') as HTMLInputElement).value).toBe('clientName')
|
|
100
|
+
expect(screen.getByText(/clientName: \{ type: 'text', label: 'Client Name' \},/)).toBeTruthy()
|
|
101
|
+
|
|
102
|
+
// A name colliding with an existing field key blocks the snippet.
|
|
103
|
+
fireEvent.change(nameInput, { target: { value: 'Featured Image' } })
|
|
104
|
+
expect(screen.getByText(/already exists on this type/)).toBeTruthy()
|
|
105
|
+
expect(screen.queryByText('Copy snippet')).toBeNull()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows a not-found notice for unknown slugs', async () => {
|
|
109
|
+
const onNavigate = vi.fn()
|
|
110
|
+
render(<PostTypeEditor slug="ghost" onNavigate={onNavigate} />)
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(screen.getByText('No post type named "ghost"')).toBeTruthy())
|
|
113
|
+
fireEvent.click(screen.getByRole('button', { name: 'Back to Post Types' }))
|
|
114
|
+
expect(onNavigate).toHaveBeenCalledWith('/posts/types')
|
|
115
|
+
})
|
|
116
|
+
})
|