@actuate-media/cms-admin 0.66.0 → 0.67.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 +21 -0
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +7 -4
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
- package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
- package/dist/__tests__/components/inspector-pane.test.js +31 -0
- package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-tree.test.js +173 -0
- package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
- package/dist/__tests__/views/dashboard.test.js +7 -3
- package/dist/__tests__/views/dashboard.test.js.map +1 -1
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
- package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
- package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
- package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
- package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +77 -1
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
- package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
- package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
- package/dist/components/ui/InspectorPane.d.ts +51 -0
- package/dist/components/ui/InspectorPane.d.ts.map +1 -0
- package/dist/components/ui/InspectorPane.js +42 -0
- package/dist/components/ui/InspectorPane.js.map +1 -0
- package/dist/components/ui/index.d.ts +2 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/lib/page-editor-service.d.ts +11 -2
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +8 -2
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-tree.d.ts +44 -0
- package/dist/lib/pages-tree.d.ts.map +1 -0
- package/dist/lib/pages-tree.js +106 -0
- package/dist/lib/pages-tree.js.map +1 -0
- package/dist/views/Dashboard.d.ts.map +1 -1
- package/dist/views/Dashboard.js +31 -2
- package/dist/views/Dashboard.js.map +1 -1
- package/dist/views/MediaBrowser.d.ts.map +1 -1
- package/dist/views/MediaBrowser.js +28 -2
- package/dist/views/MediaBrowser.js.map +1 -1
- package/dist/views/MediaSeoAudit.d.ts +25 -0
- package/dist/views/MediaSeoAudit.d.ts.map +1 -0
- package/dist/views/MediaSeoAudit.js +97 -0
- package/dist/views/MediaSeoAudit.js.map +1 -0
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +90 -10
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
- package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
- package/dist/views/Pages/PagesTreeTable.js +28 -0
- package/dist/views/Pages/PagesTreeTable.js.map +1 -0
- package/dist/views/ScriptTags.d.ts +5 -1
- package/dist/views/ScriptTags.d.ts.map +1 -1
- package/dist/views/ScriptTags.js +32 -7
- package/dist/views/ScriptTags.js.map +1 -1
- package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
- package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
- package/dist/views/dashboard/OnboardingBlock.js +71 -0
- package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
- package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +3 -3
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
- package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
- package/dist/views/page-editor/PageSettingsModal.js +42 -3
- package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
- package/package.json +2 -2
- package/src/AdminRoot.tsx +7 -3
- package/src/__tests__/components/inspector-pane.test.tsx +64 -0
- package/src/__tests__/lib/pages-tree.test.ts +197 -0
- package/src/__tests__/views/dashboard.test.tsx +7 -3
- package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
- package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
- package/src/__tests__/views/pages-list-view.test.tsx +93 -1
- package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
- package/src/components/ui/InspectorPane.tsx +129 -0
- package/src/components/ui/index.ts +10 -0
- package/src/lib/page-editor-service.ts +16 -2
- package/src/lib/pages-tree.ts +145 -0
- package/src/views/Dashboard.tsx +238 -196
- package/src/views/MediaBrowser.tsx +641 -511
- package/src/views/MediaSeoAudit.tsx +320 -0
- package/src/views/Pages/PagesListView.tsx +352 -183
- package/src/views/Pages/PagesTreeTable.tsx +339 -0
- package/src/views/ScriptTags.tsx +204 -129
- package/src/views/dashboard/OnboardingBlock.tsx +233 -0
- package/src/views/page-editor/PageSectionEditor.tsx +9 -2
- package/src/views/page-editor/PageSettingsModal.tsx +75 -2
- package/dist/views/ScriptTagEditor.d.ts +0 -6
- package/dist/views/ScriptTagEditor.d.ts.map +0 -1
- package/dist/views/ScriptTagEditor.js +0 -111
- package/dist/views/ScriptTagEditor.js.map +0 -1
- package/src/views/ScriptTagEditor.tsx +0 -353
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
InspectorPane,
|
|
6
|
+
InspectorPaneBody,
|
|
7
|
+
InspectorPaneFooter,
|
|
8
|
+
InspectorPaneHeader,
|
|
9
|
+
InspectorPaneSection,
|
|
10
|
+
SplitPaneLayout,
|
|
11
|
+
SplitPaneList,
|
|
12
|
+
} from '../../components/ui/InspectorPane.js'
|
|
13
|
+
|
|
14
|
+
function Pane({ open }: { open: boolean }) {
|
|
15
|
+
return (
|
|
16
|
+
<SplitPaneLayout>
|
|
17
|
+
<SplitPaneList>
|
|
18
|
+
<table aria-label="Rows" />
|
|
19
|
+
</SplitPaneList>
|
|
20
|
+
<InspectorPane open={open} width={420} aria-label="Row editor">
|
|
21
|
+
<InspectorPaneHeader onClose={() => {}}>
|
|
22
|
+
<span>Editing</span>
|
|
23
|
+
</InspectorPaneHeader>
|
|
24
|
+
<InspectorPaneBody>
|
|
25
|
+
<InspectorPaneSection title="Details">content</InspectorPaneSection>
|
|
26
|
+
</InspectorPaneBody>
|
|
27
|
+
<InspectorPaneFooter>
|
|
28
|
+
<button type="button">Save</button>
|
|
29
|
+
</InspectorPaneFooter>
|
|
30
|
+
</InspectorPane>
|
|
31
|
+
</SplitPaneLayout>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('InspectorPane', () => {
|
|
36
|
+
it('renders pane content with a labelled region when open', () => {
|
|
37
|
+
render(<Pane open />)
|
|
38
|
+
expect(screen.getByRole('region', { name: 'Row editor' })).toBeTruthy()
|
|
39
|
+
expect(screen.getByText('Editing')).toBeTruthy()
|
|
40
|
+
expect(screen.getByText('Details')).toBeTruthy()
|
|
41
|
+
expect(screen.getByRole('button', { name: 'Save' })).toBeTruthy()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('collapses to zero width and hides content when closed', () => {
|
|
45
|
+
const { container } = render(<Pane open={false} />)
|
|
46
|
+
const shell = container.querySelector('[aria-hidden="true"]') as HTMLElement
|
|
47
|
+
expect(shell).toBeTruthy()
|
|
48
|
+
expect(shell.style.width).toBe('0px')
|
|
49
|
+
expect(screen.queryByText('Editing')).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('close button in the header fires onClose with an accessible label', () => {
|
|
53
|
+
const onClose = vi.fn()
|
|
54
|
+
render(
|
|
55
|
+
<InspectorPane open width={400} aria-label="Detail">
|
|
56
|
+
<InspectorPaneHeader onClose={onClose} closeLabel="Close detail">
|
|
57
|
+
<span>Title</span>
|
|
58
|
+
</InspectorPaneHeader>
|
|
59
|
+
</InspectorPane>,
|
|
60
|
+
)
|
|
61
|
+
screen.getByRole('button', { name: 'Close detail' }).click()
|
|
62
|
+
expect(onClose).toHaveBeenCalledTimes(1)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildPageTree,
|
|
4
|
+
defaultExpandedIds,
|
|
5
|
+
flattenPageTree,
|
|
6
|
+
type PageTreeNode,
|
|
7
|
+
} from '../../lib/pages-tree.js'
|
|
8
|
+
import type { Page } from '../../lib/pages-service.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Unit coverage for the Pages tree hierarchy resolution. Pure functions —
|
|
12
|
+
* locked independent of the REST layer and the view. The contract:
|
|
13
|
+
* explicit `parentPageId` wins, path-prefix derivation fills the gaps,
|
|
14
|
+
* and no input (orphans, cycles, duplicates) can lose or duplicate a page.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
function page(partial: Partial<Page> & Pick<Page, 'id' | 'title' | 'path'>): Page {
|
|
18
|
+
return {
|
|
19
|
+
id: partial.id,
|
|
20
|
+
title: partial.title,
|
|
21
|
+
path: partial.path,
|
|
22
|
+
slug: partial.slug ?? partial.path.split('/').filter(Boolean).pop() ?? '',
|
|
23
|
+
parentPageId: partial.parentPageId ?? null,
|
|
24
|
+
tags: [],
|
|
25
|
+
primaryTagId: null,
|
|
26
|
+
authorId: 'u1',
|
|
27
|
+
authorName: 'Maria K.',
|
|
28
|
+
authorInitials: 'MK',
|
|
29
|
+
status: partial.status ?? 'PUBLISHED',
|
|
30
|
+
publishDate: null,
|
|
31
|
+
scheduledDate: null,
|
|
32
|
+
lastModified: '2026-05-01',
|
|
33
|
+
seoScore: null,
|
|
34
|
+
seoTitle: null,
|
|
35
|
+
seoDescription: null,
|
|
36
|
+
templateId: partial.templateId ?? null,
|
|
37
|
+
isHomepage: partial.path === '/',
|
|
38
|
+
isSystemPage: false,
|
|
39
|
+
createdAt: '2026-01-01',
|
|
40
|
+
updatedAt: '2026-05-01',
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function titles(nodes: PageTreeNode[]): string[] {
|
|
45
|
+
return nodes.map((n) => n.page.title)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function countNodes(nodes: PageTreeNode[]): number {
|
|
49
|
+
return nodes.reduce((n, node) => n + 1 + countNodes(node.children), 0)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('buildPageTree', () => {
|
|
53
|
+
it('nests children under explicit parentPageId parents', () => {
|
|
54
|
+
const roots = buildPageTree([
|
|
55
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
56
|
+
page({ id: 'team', title: 'Our Team', path: '/team-page', parentPageId: 'about' }),
|
|
57
|
+
])
|
|
58
|
+
expect(titles(roots)).toEqual(['About Us'])
|
|
59
|
+
expect(titles(roots[0]!.children)).toEqual(['Our Team'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('derives parents from path prefixes when parentPageId is null', () => {
|
|
63
|
+
const roots = buildPageTree([
|
|
64
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
65
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
66
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
67
|
+
page({ id: 'story', title: 'Our Story', path: '/about/story' }),
|
|
68
|
+
page({ id: 'contact', title: 'Contact', path: '/contact' }),
|
|
69
|
+
])
|
|
70
|
+
expect(titles(roots)).toEqual(['About Us', 'Contact', 'Home'])
|
|
71
|
+
const about = roots[0]!
|
|
72
|
+
expect(titles(about.children)).toEqual(['Our Story', 'Our Team'])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('skips missing intermediate path levels to the longest existing prefix', () => {
|
|
76
|
+
const roots = buildPageTree([
|
|
77
|
+
page({ id: 'svc', title: 'Services', path: '/services' }),
|
|
78
|
+
// No "/services/web" page exists — attaches to "/services".
|
|
79
|
+
page({ id: 'design', title: 'Web Design', path: '/services/web/design' }),
|
|
80
|
+
])
|
|
81
|
+
expect(titles(roots)).toEqual(['Services'])
|
|
82
|
+
expect(titles(roots[0]!.children)).toEqual(['Web Design'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('never derives the homepage ("/") as a path parent', () => {
|
|
86
|
+
const roots = buildPageTree([
|
|
87
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
88
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
89
|
+
])
|
|
90
|
+
expect(titles(roots)).toEqual(['Blog', 'Home'])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('prefers explicit parentPageId over the path prefix', () => {
|
|
94
|
+
const roots = buildPageTree([
|
|
95
|
+
page({ id: 'about', title: 'About', path: '/about' }),
|
|
96
|
+
page({ id: 'lp', title: 'Landing', path: '/lp' }),
|
|
97
|
+
// Path says "/about/…" but the explicit link says "lp" — link wins.
|
|
98
|
+
page({ id: 'x', title: 'Campaign', path: '/about/campaign', parentPageId: 'lp' }),
|
|
99
|
+
])
|
|
100
|
+
const lp = roots.find((n) => n.page.id === 'lp')!
|
|
101
|
+
expect(titles(lp.children)).toEqual(['Campaign'])
|
|
102
|
+
expect(roots.find((n) => n.page.id === 'about')!.children).toHaveLength(0)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('treats orphans (parent not in the set) as roots', () => {
|
|
106
|
+
const roots = buildPageTree([
|
|
107
|
+
page({ id: 'a', title: 'A', path: '/a', parentPageId: 'missing' }),
|
|
108
|
+
page({ id: 'b', title: 'B', path: '/b' }),
|
|
109
|
+
])
|
|
110
|
+
expect(titles(roots)).toEqual(['A', 'B'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('breaks parentPageId cycles to the root without losing pages', () => {
|
|
114
|
+
const roots = buildPageTree([
|
|
115
|
+
page({ id: 'a', title: 'A', path: '/x-a', parentPageId: 'b' }),
|
|
116
|
+
page({ id: 'b', title: 'B', path: '/x-b', parentPageId: 'a' }),
|
|
117
|
+
page({ id: 'c', title: 'C', path: '/x-c' }),
|
|
118
|
+
])
|
|
119
|
+
expect(countNodes(roots)).toBe(3)
|
|
120
|
+
// One of the cycle members must have been rooted.
|
|
121
|
+
const rootIds = roots.map((n) => n.page.id)
|
|
122
|
+
expect(rootIds).toContain('c')
|
|
123
|
+
expect(rootIds.some((id) => id === 'a' || id === 'b')).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('ignores a self-referencing parentPageId', () => {
|
|
127
|
+
const roots = buildPageTree([page({ id: 'a', title: 'A', path: '/a', parentPageId: 'a' })])
|
|
128
|
+
expect(titles(roots)).toEqual(['A'])
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('orders siblings by title then path, at every depth', () => {
|
|
132
|
+
const roots = buildPageTree([
|
|
133
|
+
page({ id: 'svc', title: 'Services', path: '/services' }),
|
|
134
|
+
page({ id: 's2', title: 'Web Design', path: '/services/web-design' }),
|
|
135
|
+
page({ id: 's1', title: 'CMS Solutions', path: '/services/cms' }),
|
|
136
|
+
page({ id: 's3', title: 'SEO Services', path: '/services/seo' }),
|
|
137
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
138
|
+
])
|
|
139
|
+
expect(titles(roots)).toEqual(['Blog', 'Services'])
|
|
140
|
+
expect(titles(roots.find((n) => n.page.id === 'svc')!.children)).toEqual([
|
|
141
|
+
'CMS Solutions',
|
|
142
|
+
'SEO Services',
|
|
143
|
+
'Web Design',
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('keeps every page exactly once on messy input', () => {
|
|
148
|
+
const pages = [
|
|
149
|
+
page({ id: 'home', title: 'Home', path: '/' }),
|
|
150
|
+
page({ id: 'dup1', title: 'Dup A', path: '/dup' }),
|
|
151
|
+
page({ id: 'dup2', title: 'Dup B', path: '/dup' }),
|
|
152
|
+
page({ id: 'kid', title: 'Kid', path: '/dup/kid' }),
|
|
153
|
+
page({ id: 'cyc1', title: 'Cyc 1', path: '/c1', parentPageId: 'cyc2' }),
|
|
154
|
+
page({ id: 'cyc2', title: 'Cyc 2', path: '/c2', parentPageId: 'cyc1' }),
|
|
155
|
+
page({ id: 'orph', title: 'Orphan', path: '/orphan', parentPageId: 'gone' }),
|
|
156
|
+
]
|
|
157
|
+
const roots = buildPageTree(pages)
|
|
158
|
+
expect(countNodes(roots)).toBe(pages.length)
|
|
159
|
+
// "/dup/kid" attaches to the first page seen with path "/dup".
|
|
160
|
+
expect(roots.some((n) => n.page.id === 'dup1' && n.children.length === 1)).toBe(true)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('flattenPageTree', () => {
|
|
165
|
+
const roots = buildPageTree([
|
|
166
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
167
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
168
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
169
|
+
])
|
|
170
|
+
|
|
171
|
+
it('renders children only when the parent is expanded', () => {
|
|
172
|
+
const collapsed = flattenPageTree(roots, new Set())
|
|
173
|
+
expect(collapsed.map((r) => r.page.id)).toEqual(['about', 'blog'])
|
|
174
|
+
|
|
175
|
+
const open = flattenPageTree(roots, new Set(['about']))
|
|
176
|
+
expect(open.map((r) => r.page.id)).toEqual(['about', 'team', 'blog'])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('reports depth and hasChildren per row', () => {
|
|
180
|
+
const rows = flattenPageTree(roots, new Set(['about']))
|
|
181
|
+
expect(rows[0]).toMatchObject({ depth: 0, hasChildren: true })
|
|
182
|
+
expect(rows[1]).toMatchObject({ depth: 1, hasChildren: false })
|
|
183
|
+
expect(rows[2]).toMatchObject({ depth: 0, hasChildren: false })
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('defaultExpandedIds', () => {
|
|
188
|
+
it('expands only root nodes that have children', () => {
|
|
189
|
+
const roots = buildPageTree([
|
|
190
|
+
page({ id: 'about', title: 'About Us', path: '/about' }),
|
|
191
|
+
page({ id: 'team', title: 'Our Team', path: '/about/team' }),
|
|
192
|
+
page({ id: 'deep', title: 'Deep', path: '/about/team/deep' }),
|
|
193
|
+
page({ id: 'blog', title: 'Blog', path: '/blog' }),
|
|
194
|
+
])
|
|
195
|
+
expect([...defaultExpandedIds(roots)]).toEqual(['about'])
|
|
196
|
+
})
|
|
197
|
+
})
|
|
@@ -59,13 +59,17 @@ describe('Dashboard', () => {
|
|
|
59
59
|
expect(screen.queryByText('Recent Activity')).toBeNull()
|
|
60
60
|
})
|
|
61
61
|
|
|
62
|
-
it('shows
|
|
62
|
+
it('shows the onboarding block instead of the activity grid when there is no content', () => {
|
|
63
63
|
responses['/stats'] = statsResult()
|
|
64
64
|
|
|
65
65
|
render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// An effectively-empty site (zero posts+pages, zero media) swaps the
|
|
68
|
+
// Recent Activity / Publishing Queue / Content Health grid for the
|
|
69
|
+
// getting-started onboarding block.
|
|
70
|
+
expect(screen.getByText(/Welcome to ActuateCMS/)).toBeTruthy()
|
|
71
|
+
expect(screen.queryByText('No activity yet')).toBeNull()
|
|
72
|
+
expect(screen.queryByText('No scheduled content')).toBeNull()
|
|
69
73
|
})
|
|
70
74
|
|
|
71
75
|
it('surfaces a retryable banner when the stats request is exhausted', () => {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
import { MediaSeoAudit, type MediaSeoAuditItem } from '../../views/MediaSeoAudit.js'
|
|
6
|
+
|
|
7
|
+
// Scores mirror what cms-core's `calculateMediaSeoScore()` would return for
|
|
8
|
+
// each row — the component never re-scores, it renders `seoScore` as-is.
|
|
9
|
+
const ITEMS: MediaSeoAuditItem[] = [
|
|
10
|
+
{
|
|
11
|
+
id: 'complete',
|
|
12
|
+
name: 'seattle-team.webp',
|
|
13
|
+
type: 'image/webp',
|
|
14
|
+
size: '120 KB',
|
|
15
|
+
url: 'https://cdn.example.com/seattle-team.webp',
|
|
16
|
+
format: 'webp',
|
|
17
|
+
altTag: 'Team collaborating in the Seattle office',
|
|
18
|
+
title: 'Seattle Team',
|
|
19
|
+
description: 'The team collaborating in the Seattle office.',
|
|
20
|
+
seoScore: 100,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'no-alt',
|
|
24
|
+
name: 'hero-banner.jpg',
|
|
25
|
+
type: 'image/jpeg',
|
|
26
|
+
size: '342 KB',
|
|
27
|
+
url: 'https://cdn.example.com/hero-banner.jpg',
|
|
28
|
+
format: 'jpeg',
|
|
29
|
+
altTag: '',
|
|
30
|
+
title: 'Hero Banner',
|
|
31
|
+
description: 'Homepage hero banner.',
|
|
32
|
+
seoScore: 70,
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'bare',
|
|
36
|
+
name: 'IMG_4892.jpg',
|
|
37
|
+
type: 'image/jpeg',
|
|
38
|
+
size: '1.8 MB',
|
|
39
|
+
url: 'https://cdn.example.com/IMG_4892.jpg',
|
|
40
|
+
format: 'jpeg',
|
|
41
|
+
altTag: '',
|
|
42
|
+
title: '',
|
|
43
|
+
description: '',
|
|
44
|
+
seoScore: 0,
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
describe('MediaSeoAudit', () => {
|
|
49
|
+
it('renders the four summary stat cards from item metadata', () => {
|
|
50
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={vi.fn()} />)
|
|
51
|
+
|
|
52
|
+
// Avg = round((100 + 70 + 0) / 3) = 57 → "Fair"
|
|
53
|
+
expect(screen.getByText('57/100')).toBeTruthy()
|
|
54
|
+
expect(screen.getByText('Avg SEO Score')).toBeTruthy()
|
|
55
|
+
expect(screen.getByText('Fair')).toBeTruthy()
|
|
56
|
+
|
|
57
|
+
expect(screen.getByText('Missing Alt Text')).toBeTruthy()
|
|
58
|
+
expect(screen.getByText('1 have alt text')).toBeTruthy()
|
|
59
|
+
expect(screen.getByText('Missing Titles')).toBeTruthy()
|
|
60
|
+
expect(screen.getByText('2 have titles')).toBeTruthy()
|
|
61
|
+
expect(screen.getByText('Missing Descriptions')).toBeTruthy()
|
|
62
|
+
expect(screen.getByText('2 have descriptions')).toBeTruthy()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('shows all items by default with a right-aligned count', () => {
|
|
66
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={vi.fn()} />)
|
|
67
|
+
|
|
68
|
+
const table = screen.getByRole('table', { name: 'Media SEO audit' })
|
|
69
|
+
expect(within(table).getAllByRole('row')).toHaveLength(4) // header + 3 items
|
|
70
|
+
expect(screen.getByText('3 items')).toBeTruthy()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('filters to items missing alt text', () => {
|
|
74
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={vi.fn()} />)
|
|
75
|
+
|
|
76
|
+
fireEvent.click(screen.getByRole('button', { name: 'Missing Alt' }))
|
|
77
|
+
|
|
78
|
+
const table = screen.getByRole('table', { name: 'Media SEO audit' })
|
|
79
|
+
expect(within(table).getAllByRole('row')).toHaveLength(3) // header + 2 items
|
|
80
|
+
expect(within(table).queryByText('seattle-team.webp')).toBeNull()
|
|
81
|
+
expect(screen.getByText('2 items')).toBeTruthy()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('filters to poor scores (score < 40)', () => {
|
|
85
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={vi.fn()} />)
|
|
86
|
+
|
|
87
|
+
fireEvent.click(screen.getByRole('button', { name: 'Poor score' }))
|
|
88
|
+
|
|
89
|
+
const table = screen.getByRole('table', { name: 'Media SEO audit' })
|
|
90
|
+
expect(within(table).getAllByRole('row')).toHaveLength(2) // header + 1 item
|
|
91
|
+
expect(within(table).getByText('IMG_4892.jpg')).toBeTruthy()
|
|
92
|
+
expect(screen.getByText('1 item')).toBeTruthy()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('fires onEdit with the row item from the Edit button', () => {
|
|
96
|
+
const onEdit = vi.fn()
|
|
97
|
+
render(<MediaSeoAudit items={ITEMS} onEdit={onEdit} />)
|
|
98
|
+
|
|
99
|
+
const buttons = screen.getAllByRole('button', { name: 'Edit' })
|
|
100
|
+
expect(buttons).toHaveLength(3)
|
|
101
|
+
fireEvent.click(buttons[1]!)
|
|
102
|
+
expect(onEdit).toHaveBeenCalledTimes(1)
|
|
103
|
+
expect(onEdit.mock.calls[0]![0].id).toBe('no-alt')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('shows an empty state when there is nothing to audit', () => {
|
|
107
|
+
render(<MediaSeoAudit items={[]} onEdit={vi.fn()} />)
|
|
108
|
+
expect(screen.getByText('No media to audit')).toBeTruthy()
|
|
109
|
+
expect(screen.queryByRole('table')).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('shows a filter-specific empty state when a filter matches nothing', () => {
|
|
113
|
+
render(<MediaSeoAudit items={[ITEMS[0]!]} onEdit={vi.fn()} />)
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: 'Missing Alt' }))
|
|
115
|
+
expect(screen.getByText('No items match this filter')).toBeTruthy()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
4
|
+
import {
|
|
5
|
+
OnboardingBlock,
|
|
6
|
+
deriveOnboardingSteps,
|
|
7
|
+
shouldShowOnboarding,
|
|
8
|
+
type OnboardingSignals,
|
|
9
|
+
} from '../../views/dashboard/OnboardingBlock.js'
|
|
10
|
+
|
|
11
|
+
function signals(over: Partial<OnboardingSignals> = {}): OnboardingSignals {
|
|
12
|
+
return {
|
|
13
|
+
postsAndPagesCount: 0,
|
|
14
|
+
mediaCount: 0,
|
|
15
|
+
seoScore: 0,
|
|
16
|
+
userCount: 1,
|
|
17
|
+
hasApiTraffic: false,
|
|
18
|
+
...over,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('deriveOnboardingSteps', () => {
|
|
23
|
+
it('marks only the workspace step done on a brand-new site', () => {
|
|
24
|
+
const steps = deriveOnboardingSteps(signals())
|
|
25
|
+
expect(steps).toHaveLength(6)
|
|
26
|
+
expect(steps.filter((s) => s.done).map((s) => s.id)).toEqual(['workspace'])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('derives each step from its dashboard signal', () => {
|
|
30
|
+
const steps = deriveOnboardingSteps(
|
|
31
|
+
signals({
|
|
32
|
+
postsAndPagesCount: 3,
|
|
33
|
+
mediaCount: 2,
|
|
34
|
+
seoScore: 71,
|
|
35
|
+
userCount: 4,
|
|
36
|
+
hasApiTraffic: true,
|
|
37
|
+
}),
|
|
38
|
+
)
|
|
39
|
+
expect(steps.every((s) => s.done)).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('treats a lone user and zero SEO score as incomplete steps', () => {
|
|
43
|
+
const steps = deriveOnboardingSteps(signals({ userCount: 1, seoScore: 0 }))
|
|
44
|
+
const byId = Object.fromEntries(steps.map((s) => [s.id, s.done]))
|
|
45
|
+
expect(byId['invite']).toBe(false)
|
|
46
|
+
expect(byId['seo']).toBe(false)
|
|
47
|
+
expect(byId['api-key']).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('shouldShowOnboarding', () => {
|
|
52
|
+
it('shows only when both posts+pages and media are empty', () => {
|
|
53
|
+
expect(shouldShowOnboarding(signals())).toBe(true)
|
|
54
|
+
expect(shouldShowOnboarding(signals({ postsAndPagesCount: 1 }))).toBe(false)
|
|
55
|
+
expect(shouldShowOnboarding(signals({ mediaCount: 1 }))).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('OnboardingBlock', () => {
|
|
60
|
+
it('renders the welcome banner with the derived progress count', () => {
|
|
61
|
+
render(<OnboardingBlock signals={signals()} onNavigate={() => {}} />)
|
|
62
|
+
|
|
63
|
+
expect(screen.getByText(/Welcome to ActuateCMS/)).toBeTruthy()
|
|
64
|
+
// 1 of 6 done (workspace only) on a fresh site.
|
|
65
|
+
expect(screen.getByText(/1\/6 steps complete/)).toBeTruthy()
|
|
66
|
+
|
|
67
|
+
const bar = screen.getByRole('progressbar', { name: 'Onboarding progress' })
|
|
68
|
+
expect(bar.getAttribute('aria-valuenow')).toBe('1')
|
|
69
|
+
expect(bar.getAttribute('aria-valuemax')).toBe('6')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('updates the progress count as steps derive complete', () => {
|
|
73
|
+
render(
|
|
74
|
+
<OnboardingBlock signals={signals({ userCount: 3, seoScore: 50 })} onNavigate={() => {}} />,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
expect(screen.getByText(/3\/6 steps complete/)).toBeTruthy()
|
|
78
|
+
expect(
|
|
79
|
+
screen
|
|
80
|
+
.getByRole('progressbar', { name: 'Onboarding progress' })
|
|
81
|
+
.getAttribute('aria-valuenow'),
|
|
82
|
+
).toBe('3')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders all six checklist labels with accessible completion state', () => {
|
|
86
|
+
render(<OnboardingBlock signals={signals()} onNavigate={() => {}} />)
|
|
87
|
+
|
|
88
|
+
for (const label of [
|
|
89
|
+
'Create your workspace',
|
|
90
|
+
'Create your first Post or Page',
|
|
91
|
+
'Upload media assets',
|
|
92
|
+
'Configure SEO defaults',
|
|
93
|
+
'Get your API key',
|
|
94
|
+
'Invite team members',
|
|
95
|
+
]) {
|
|
96
|
+
expect(screen.getByText(label)).toBeTruthy()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Completion is announced via text, not color alone.
|
|
100
|
+
expect(screen.getAllByText('(completed)')).toHaveLength(1)
|
|
101
|
+
expect(screen.getAllByText('(not completed)')).toHaveLength(5)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('navigates through quick links via onNavigate', () => {
|
|
105
|
+
const onNavigate = vi.fn()
|
|
106
|
+
render(<OnboardingBlock signals={signals()} onNavigate={onNavigate} />)
|
|
107
|
+
|
|
108
|
+
fireEvent.click(screen.getByRole('button', { name: /Invite team/ }))
|
|
109
|
+
expect(onNavigate).toHaveBeenCalledWith('/settings?tab=users')
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: /API Reference/ }))
|
|
112
|
+
expect(onNavigate).toHaveBeenCalledWith('/settings?tab=api-keys')
|
|
113
|
+
|
|
114
|
+
fireEvent.click(screen.getByRole('button', { name: /SEO defaults/ }))
|
|
115
|
+
expect(onNavigate).toHaveBeenCalledWith('/settings?tab=seo')
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ── Dashboard integration: visibility rule ─────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const responses: Record<string, unknown> = {}
|
|
122
|
+
const emptyResult = { data: null, loading: false, error: null, exhausted: false, refetch: () => {} }
|
|
123
|
+
|
|
124
|
+
vi.mock('../../lib/useApiData.js', () => ({
|
|
125
|
+
useApiData: (endpoint: string | null) => responses[endpoint ?? ''] ?? emptyResult,
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
const { Dashboard } = await import('../../views/Dashboard.js')
|
|
129
|
+
|
|
130
|
+
const baseStats = {
|
|
131
|
+
totalDocuments: 0,
|
|
132
|
+
totalMedia: 0,
|
|
133
|
+
totalUsers: 1,
|
|
134
|
+
formCount: 0,
|
|
135
|
+
avgSeoScore: 0,
|
|
136
|
+
webhookCount: 0,
|
|
137
|
+
webhookActiveCount: 0,
|
|
138
|
+
collectionCounts: {} as Record<string, number>,
|
|
139
|
+
statusCounts: {},
|
|
140
|
+
recentDocuments: [] as unknown[],
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function statsResult(over: Partial<typeof baseStats> = {}) {
|
|
144
|
+
return {
|
|
145
|
+
data: { ...baseStats, ...over },
|
|
146
|
+
loading: false,
|
|
147
|
+
error: null,
|
|
148
|
+
exhausted: false,
|
|
149
|
+
refetch: () => {},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
describe('Dashboard onboarding visibility', () => {
|
|
154
|
+
it('replaces the activity grid with onboarding when the site is empty', () => {
|
|
155
|
+
responses['/stats'] = statsResult()
|
|
156
|
+
|
|
157
|
+
render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
|
|
158
|
+
|
|
159
|
+
expect(screen.getByText(/Welcome to ActuateCMS/)).toBeTruthy()
|
|
160
|
+
expect(screen.queryByText('Recent Activity')).toBeNull()
|
|
161
|
+
expect(screen.queryByText('Publishing Queue')).toBeNull()
|
|
162
|
+
expect(screen.queryByText('Content Health')).toBeNull()
|
|
163
|
+
// The Delivery API card below the block stays.
|
|
164
|
+
expect(screen.getByText('Delivery API')).toBeTruthy()
|
|
165
|
+
delete responses['/stats']
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('renders the normal dashboard when posts or pages exist', () => {
|
|
169
|
+
responses['/stats'] = statsResult({ collectionCounts: { posts: 2 } })
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<Dashboard
|
|
173
|
+
config={{ collections: [{ slug: 'posts', type: 'post' }] }}
|
|
174
|
+
session={{}}
|
|
175
|
+
onNavigate={() => {}}
|
|
176
|
+
/>,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
expect(screen.queryByText(/Welcome to ActuateCMS/)).toBeNull()
|
|
180
|
+
expect(screen.getByText('Recent Activity')).toBeTruthy()
|
|
181
|
+
delete responses['/stats']
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('renders the normal dashboard when only media exists', () => {
|
|
185
|
+
responses['/stats'] = statsResult({ totalMedia: 5 })
|
|
186
|
+
|
|
187
|
+
render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
|
|
188
|
+
|
|
189
|
+
expect(screen.queryByText(/Welcome to ActuateCMS/)).toBeNull()
|
|
190
|
+
expect(screen.getByText('Recent Activity')).toBeTruthy()
|
|
191
|
+
delete responses['/stats']
|
|
192
|
+
})
|
|
193
|
+
})
|