@actuate-media/cms-admin 0.66.0 → 0.68.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.
Files changed (244) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/AdminRoot.d.ts.map +1 -1
  3. package/dist/AdminRoot.js +36 -9
  4. package/dist/AdminRoot.js.map +1 -1
  5. package/dist/__tests__/components/inspector-pane.test.d.ts +2 -0
  6. package/dist/__tests__/components/inspector-pane.test.d.ts.map +1 -0
  7. package/dist/__tests__/components/inspector-pane.test.js +31 -0
  8. package/dist/__tests__/components/inspector-pane.test.js.map +1 -0
  9. package/dist/__tests__/hooks/useIdleLogout.test.d.ts +2 -0
  10. package/dist/__tests__/hooks/useIdleLogout.test.d.ts.map +1 -0
  11. package/dist/__tests__/hooks/useIdleLogout.test.js +64 -0
  12. package/dist/__tests__/hooks/useIdleLogout.test.js.map +1 -0
  13. package/dist/__tests__/lib/pages-tree.test.d.ts +2 -0
  14. package/dist/__tests__/lib/pages-tree.test.d.ts.map +1 -0
  15. package/dist/__tests__/lib/pages-tree.test.js +173 -0
  16. package/dist/__tests__/lib/pages-tree.test.js.map +1 -0
  17. package/dist/__tests__/lib/post-types-service.test.d.ts +2 -0
  18. package/dist/__tests__/lib/post-types-service.test.d.ts.map +1 -0
  19. package/dist/__tests__/lib/post-types-service.test.js +88 -0
  20. package/dist/__tests__/lib/post-types-service.test.js.map +1 -0
  21. package/dist/__tests__/views/collection-list-nav.render.test.js +43 -5
  22. package/dist/__tests__/views/collection-list-nav.render.test.js.map +1 -1
  23. package/dist/__tests__/views/dashboard.test.js +7 -3
  24. package/dist/__tests__/views/dashboard.test.js.map +1 -1
  25. package/dist/__tests__/views/forms-list.render.test.js +25 -6
  26. package/dist/__tests__/views/forms-list.render.test.js.map +1 -1
  27. package/dist/__tests__/views/media-seo-audit.render.test.d.ts +2 -0
  28. package/dist/__tests__/views/media-seo-audit.render.test.d.ts.map +1 -0
  29. package/dist/__tests__/views/media-seo-audit.render.test.js +102 -0
  30. package/dist/__tests__/views/media-seo-audit.render.test.js.map +1 -0
  31. package/dist/__tests__/views/onboarding-block.render.test.d.ts +2 -0
  32. package/dist/__tests__/views/onboarding-block.render.test.d.ts.map +1 -0
  33. package/dist/__tests__/views/onboarding-block.render.test.js +146 -0
  34. package/dist/__tests__/views/onboarding-block.render.test.js.map +1 -0
  35. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts +2 -0
  36. package/dist/__tests__/views/page-builder-sections-panel.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/views/page-builder-sections-panel.render.test.js +121 -0
  38. package/dist/__tests__/views/page-builder-sections-panel.render.test.js.map +1 -0
  39. package/dist/__tests__/views/page-templates.render.test.d.ts +2 -0
  40. package/dist/__tests__/views/page-templates.render.test.d.ts.map +1 -0
  41. package/dist/__tests__/views/page-templates.render.test.js +105 -0
  42. package/dist/__tests__/views/page-templates.render.test.js.map +1 -0
  43. package/dist/__tests__/views/pages-list-view.test.js +77 -1
  44. package/dist/__tests__/views/pages-list-view.test.js.map +1 -1
  45. package/dist/__tests__/views/post-type-editor.render.test.d.ts +2 -0
  46. package/dist/__tests__/views/post-type-editor.render.test.d.ts.map +1 -0
  47. package/dist/__tests__/views/post-type-editor.render.test.js +102 -0
  48. package/dist/__tests__/views/post-type-editor.render.test.js.map +1 -0
  49. package/dist/__tests__/views/security-settings.render.test.js +85 -4
  50. package/dist/__tests__/views/security-settings.render.test.js.map +1 -1
  51. package/dist/__tests__/views/seo-content-pane.render.test.d.ts +2 -0
  52. package/dist/__tests__/views/seo-content-pane.render.test.d.ts.map +1 -0
  53. package/dist/__tests__/views/seo-content-pane.render.test.js +117 -0
  54. package/dist/__tests__/views/seo-content-pane.render.test.js.map +1 -0
  55. package/dist/actuate-admin.css +1 -1
  56. package/dist/components/collections/EntryDetailPane.d.ts +10 -0
  57. package/dist/components/collections/EntryDetailPane.d.ts.map +1 -0
  58. package/dist/components/collections/EntryDetailPane.js +92 -0
  59. package/dist/components/collections/EntryDetailPane.js.map +1 -0
  60. package/dist/components/forms/FieldsPanel.js +1 -1
  61. package/dist/components/forms/FieldsPanel.js.map +1 -1
  62. package/dist/components/forms/FormSchemaPane.d.ts +8 -0
  63. package/dist/components/forms/FormSchemaPane.d.ts.map +1 -0
  64. package/dist/components/forms/FormSchemaPane.js +95 -0
  65. package/dist/components/forms/FormSchemaPane.js.map +1 -0
  66. package/dist/components/script-tags/ScriptTagEditorPane.d.ts +26 -0
  67. package/dist/components/script-tags/ScriptTagEditorPane.d.ts.map +1 -0
  68. package/dist/components/script-tags/ScriptTagEditorPane.js +160 -0
  69. package/dist/components/script-tags/ScriptTagEditorPane.js.map +1 -0
  70. package/dist/components/seo/SeoEditorPane.d.ts +9 -0
  71. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -0
  72. package/dist/components/seo/SeoEditorPane.js +175 -0
  73. package/dist/components/seo/SeoEditorPane.js.map +1 -0
  74. package/dist/components/ui/Badge.d.ts +1 -1
  75. package/dist/components/ui/Button.d.ts +1 -1
  76. package/dist/components/ui/InspectorPane.d.ts +51 -0
  77. package/dist/components/ui/InspectorPane.d.ts.map +1 -0
  78. package/dist/components/ui/InspectorPane.js +42 -0
  79. package/dist/components/ui/InspectorPane.js.map +1 -0
  80. package/dist/components/ui/StatusBadge.d.ts +1 -1
  81. package/dist/components/ui/index.d.ts +2 -0
  82. package/dist/components/ui/index.d.ts.map +1 -1
  83. package/dist/components/ui/index.js +1 -0
  84. package/dist/components/ui/index.js.map +1 -1
  85. package/dist/hooks/useIdleLogout.d.ts +18 -0
  86. package/dist/hooks/useIdleLogout.d.ts.map +1 -0
  87. package/dist/hooks/useIdleLogout.js +66 -0
  88. package/dist/hooks/useIdleLogout.js.map +1 -0
  89. package/dist/lib/api.d.ts +1 -0
  90. package/dist/lib/api.d.ts.map +1 -1
  91. package/dist/lib/api.js +7 -1
  92. package/dist/lib/api.js.map +1 -1
  93. package/dist/lib/page-editor-service.d.ts +11 -2
  94. package/dist/lib/page-editor-service.d.ts.map +1 -1
  95. package/dist/lib/page-editor-service.js +8 -2
  96. package/dist/lib/page-editor-service.js.map +1 -1
  97. package/dist/lib/pages-tree.d.ts +44 -0
  98. package/dist/lib/pages-tree.d.ts.map +1 -0
  99. package/dist/lib/pages-tree.js +106 -0
  100. package/dist/lib/pages-tree.js.map +1 -0
  101. package/dist/lib/post-types-service.d.ts +44 -0
  102. package/dist/lib/post-types-service.d.ts.map +1 -0
  103. package/dist/lib/post-types-service.js +83 -0
  104. package/dist/lib/post-types-service.js.map +1 -0
  105. package/dist/views/CollectionList.d.ts.map +1 -1
  106. package/dist/views/CollectionList.js +23 -4
  107. package/dist/views/CollectionList.js.map +1 -1
  108. package/dist/views/Dashboard.d.ts.map +1 -1
  109. package/dist/views/Dashboard.js +31 -2
  110. package/dist/views/Dashboard.js.map +1 -1
  111. package/dist/views/Forms.d.ts.map +1 -1
  112. package/dist/views/Forms.js +25 -21
  113. package/dist/views/Forms.js.map +1 -1
  114. package/dist/views/MediaBrowser.d.ts.map +1 -1
  115. package/dist/views/MediaBrowser.js +28 -2
  116. package/dist/views/MediaBrowser.js.map +1 -1
  117. package/dist/views/MediaSeoAudit.d.ts +25 -0
  118. package/dist/views/MediaSeoAudit.d.ts.map +1 -0
  119. package/dist/views/MediaSeoAudit.js +97 -0
  120. package/dist/views/MediaSeoAudit.js.map +1 -0
  121. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  122. package/dist/views/Pages/PagesListView.js +90 -10
  123. package/dist/views/Pages/PagesListView.js.map +1 -1
  124. package/dist/views/Pages/PagesTreeTable.d.ts +35 -0
  125. package/dist/views/Pages/PagesTreeTable.d.ts.map +1 -0
  126. package/dist/views/Pages/PagesTreeTable.js +28 -0
  127. package/dist/views/Pages/PagesTreeTable.js.map +1 -0
  128. package/dist/views/Posts/PostTypeEditor.d.ts +16 -0
  129. package/dist/views/Posts/PostTypeEditor.d.ts.map +1 -0
  130. package/dist/views/Posts/PostTypeEditor.js +159 -0
  131. package/dist/views/Posts/PostTypeEditor.js.map +1 -0
  132. package/dist/views/Posts/PostTypesView.d.ts +3 -2
  133. package/dist/views/Posts/PostTypesView.d.ts.map +1 -1
  134. package/dist/views/Posts/PostTypesView.js +1 -1
  135. package/dist/views/Posts/PostTypesView.js.map +1 -1
  136. package/dist/views/ScriptTags.d.ts +5 -1
  137. package/dist/views/ScriptTags.d.ts.map +1 -1
  138. package/dist/views/ScriptTags.js +32 -7
  139. package/dist/views/ScriptTags.js.map +1 -1
  140. package/dist/views/dashboard/OnboardingBlock.d.ts +29 -0
  141. package/dist/views/dashboard/OnboardingBlock.d.ts.map +1 -0
  142. package/dist/views/dashboard/OnboardingBlock.js +71 -0
  143. package/dist/views/dashboard/OnboardingBlock.js.map +1 -0
  144. package/dist/views/page-builder/BuilderSectionsPanel.d.ts +20 -0
  145. package/dist/views/page-builder/BuilderSectionsPanel.d.ts.map +1 -0
  146. package/dist/views/page-builder/BuilderSectionsPanel.js +105 -0
  147. package/dist/views/page-builder/BuilderSectionsPanel.js.map +1 -0
  148. package/dist/views/page-builder/PageBuilder.d.ts.map +1 -1
  149. package/dist/views/page-builder/PageBuilder.js +51 -2
  150. package/dist/views/page-builder/PageBuilder.js.map +1 -1
  151. package/dist/views/page-builder/PageTemplates.d.ts.map +1 -1
  152. package/dist/views/page-builder/PageTemplates.js +113 -6
  153. package/dist/views/page-builder/PageTemplates.js.map +1 -1
  154. package/dist/views/page-editor/PageSectionEditor.d.ts +7 -1
  155. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  156. package/dist/views/page-editor/PageSectionEditor.js +3 -3
  157. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  158. package/dist/views/page-editor/PageSettingsModal.d.ts +1 -1
  159. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -1
  160. package/dist/views/page-editor/PageSettingsModal.js +42 -3
  161. package/dist/views/page-editor/PageSettingsModal.js.map +1 -1
  162. package/dist/views/seo/ContentTab.d.ts.map +1 -1
  163. package/dist/views/seo/ContentTab.js +22 -6
  164. package/dist/views/seo/ContentTab.js.map +1 -1
  165. package/dist/views/settings/AccessProtectionCard.d.ts +5 -1
  166. package/dist/views/settings/AccessProtectionCard.d.ts.map +1 -1
  167. package/dist/views/settings/AccessProtectionCard.js +13 -10
  168. package/dist/views/settings/AccessProtectionCard.js.map +1 -1
  169. package/dist/views/settings/SecurityTab.d.ts.map +1 -1
  170. package/dist/views/settings/SecurityTab.js +3 -3
  171. package/dist/views/settings/SecurityTab.js.map +1 -1
  172. package/dist/views/settings/SessionPolicyCard.d.ts +1 -2
  173. package/dist/views/settings/SessionPolicyCard.d.ts.map +1 -1
  174. package/dist/views/settings/SessionPolicyCard.js +12 -5
  175. package/dist/views/settings/SessionPolicyCard.js.map +1 -1
  176. package/dist/views/settings/useSecuritySettings.d.ts +13 -1
  177. package/dist/views/settings/useSecuritySettings.d.ts.map +1 -1
  178. package/dist/views/settings/useSecuritySettings.js +47 -4
  179. package/dist/views/settings/useSecuritySettings.js.map +1 -1
  180. package/package.json +2 -2
  181. package/src/AdminRoot.tsx +43 -10
  182. package/src/__tests__/components/inspector-pane.test.tsx +64 -0
  183. package/src/__tests__/hooks/useIdleLogout.test.tsx +86 -0
  184. package/src/__tests__/lib/pages-tree.test.ts +197 -0
  185. package/src/__tests__/lib/post-types-service.test.ts +112 -0
  186. package/src/__tests__/views/collection-list-nav.render.test.tsx +51 -5
  187. package/src/__tests__/views/dashboard.test.tsx +7 -3
  188. package/src/__tests__/views/forms-list.render.test.tsx +34 -6
  189. package/src/__tests__/views/media-seo-audit.render.test.tsx +117 -0
  190. package/src/__tests__/views/onboarding-block.render.test.tsx +193 -0
  191. package/src/__tests__/views/page-builder-sections-panel.render.test.tsx +138 -0
  192. package/src/__tests__/views/page-templates.render.test.tsx +142 -0
  193. package/src/__tests__/views/pages-list-view.test.tsx +93 -1
  194. package/src/__tests__/views/post-type-editor.render.test.tsx +116 -0
  195. package/src/__tests__/views/security-settings.render.test.tsx +111 -4
  196. package/src/__tests__/views/seo-content-pane.render.test.tsx +142 -0
  197. package/src/components/collections/EntryDetailPane.tsx +182 -0
  198. package/src/components/forms/FieldsPanel.tsx +3 -1
  199. package/src/components/forms/FormSchemaPane.tsx +192 -0
  200. package/src/components/script-tags/ScriptTagEditorPane.tsx +474 -0
  201. package/src/components/seo/SeoEditorPane.tsx +533 -0
  202. package/src/components/ui/InspectorPane.tsx +129 -0
  203. package/src/components/ui/index.ts +10 -0
  204. package/src/hooks/useIdleLogout.ts +77 -0
  205. package/src/lib/api.ts +8 -2
  206. package/src/lib/page-editor-service.ts +16 -2
  207. package/src/lib/pages-tree.ts +145 -0
  208. package/src/lib/post-types-service.ts +129 -0
  209. package/src/views/CollectionList.tsx +158 -110
  210. package/src/views/Dashboard.tsx +238 -196
  211. package/src/views/Forms.tsx +154 -126
  212. package/src/views/MediaBrowser.tsx +641 -511
  213. package/src/views/MediaSeoAudit.tsx +320 -0
  214. package/src/views/Pages/PagesListView.tsx +352 -183
  215. package/src/views/Pages/PagesTreeTable.tsx +339 -0
  216. package/src/views/Posts/PostTypeEditor.tsx +568 -0
  217. package/src/views/Posts/PostTypesView.tsx +5 -4
  218. package/src/views/ScriptTags.tsx +204 -129
  219. package/src/views/dashboard/OnboardingBlock.tsx +233 -0
  220. package/src/views/page-builder/BuilderSectionsPanel.tsx +304 -0
  221. package/src/views/page-builder/PageBuilder.tsx +69 -2
  222. package/src/views/page-builder/PageTemplates.tsx +340 -57
  223. package/src/views/page-editor/PageSectionEditor.tsx +9 -2
  224. package/src/views/page-editor/PageSettingsModal.tsx +75 -2
  225. package/src/views/seo/ContentTab.tsx +113 -80
  226. package/src/views/settings/AccessProtectionCard.tsx +83 -53
  227. package/src/views/settings/SecurityTab.tsx +23 -2
  228. package/src/views/settings/SessionPolicyCard.tsx +45 -9
  229. package/src/views/settings/useSecuritySettings.ts +78 -22
  230. package/dist/components/forms/FormSchemaDrawer.d.ts +0 -9
  231. package/dist/components/forms/FormSchemaDrawer.d.ts.map +0 -1
  232. package/dist/components/forms/FormSchemaDrawer.js +0 -99
  233. package/dist/components/forms/FormSchemaDrawer.js.map +0 -1
  234. package/dist/components/seo/SeoEditorDrawer.d.ts +0 -8
  235. package/dist/components/seo/SeoEditorDrawer.d.ts.map +0 -1
  236. package/dist/components/seo/SeoEditorDrawer.js +0 -172
  237. package/dist/components/seo/SeoEditorDrawer.js.map +0 -1
  238. package/dist/views/ScriptTagEditor.d.ts +0 -6
  239. package/dist/views/ScriptTagEditor.d.ts.map +0 -1
  240. package/dist/views/ScriptTagEditor.js +0 -111
  241. package/dist/views/ScriptTagEditor.js.map +0 -1
  242. package/src/components/forms/FormSchemaDrawer.tsx +0 -192
  243. package/src/components/seo/SeoEditorDrawer.tsx +0 -529
  244. package/src/views/ScriptTagEditor.tsx +0 -353
@@ -0,0 +1,112 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ const cmsApiMock = vi.fn()
4
+ vi.mock('../../lib/api.js', () => ({
5
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
6
+ }))
7
+
8
+ const {
9
+ fetchPostTypeDetail,
10
+ fieldKeyFromName,
11
+ fieldTypeLabel,
12
+ humanizeFieldName,
13
+ renderFieldSnippet,
14
+ } = await import('../../lib/post-types-service.js')
15
+
16
+ const COLLECTIONS = [
17
+ {
18
+ slug: 'blog',
19
+ labels: { singular: 'Blog Post', plural: 'Blog' },
20
+ type: 'post',
21
+ urlPrefix: 'blog',
22
+ description: 'Long-form articles',
23
+ icon: 'file-text',
24
+ color: 'purple',
25
+ hidden: false,
26
+ fields: [
27
+ { name: 'title', type: 'text', required: true },
28
+ { name: 'body', type: 'richText', required: false },
29
+ { name: 'author', type: 'relationship', required: false, relationTo: 'users' },
30
+ ],
31
+ },
32
+ ]
33
+
34
+ describe('fetchPostTypeDetail', () => {
35
+ beforeEach(() => {
36
+ cmsApiMock.mockReset()
37
+ cmsApiMock.mockResolvedValue({ data: COLLECTIONS })
38
+ })
39
+
40
+ it('maps the matching collection row onto PostTypeDetail', async () => {
41
+ const detail = await fetchPostTypeDetail('blog')
42
+ expect(detail).toEqual({
43
+ slug: 'blog',
44
+ singular: 'Blog Post',
45
+ plural: 'Blog',
46
+ description: 'Long-form articles',
47
+ urlPrefix: 'blog',
48
+ icon: 'file-text',
49
+ color: 'purple',
50
+ fields: COLLECTIONS[0]!.fields,
51
+ })
52
+ })
53
+
54
+ it('returns null when the slug is unknown', async () => {
55
+ expect(await fetchPostTypeDetail('missing')).toBeNull()
56
+ })
57
+
58
+ it('throws on an API error envelope', async () => {
59
+ cmsApiMock.mockResolvedValue({ error: 'boom' })
60
+ await expect(fetchPostTypeDetail('blog')).rejects.toThrow('boom')
61
+ })
62
+ })
63
+
64
+ describe('field name helpers', () => {
65
+ it('humanizes camelCase and snake_case keys', () => {
66
+ expect(humanizeFieldName('clientName')).toBe('Client Name')
67
+ expect(humanizeFieldName('meta_description')).toBe('Meta Description')
68
+ expect(humanizeFieldName('seo-score')).toBe('Seo Score')
69
+ })
70
+
71
+ it('derives camelCase keys from display names', () => {
72
+ expect(fieldKeyFromName('Client Name')).toBe('clientName')
73
+ expect(fieldKeyFromName(' Featured Image! ')).toBe('featuredImage')
74
+ expect(fieldKeyFromName('')).toBe('')
75
+ })
76
+
77
+ it('labels known field types and passes through unknown ones', () => {
78
+ expect(fieldTypeLabel('text')).toBe('Short Text')
79
+ expect(fieldTypeLabel('relationship')).toBe('Relation')
80
+ expect(fieldTypeLabel('customThing')).toBe('customThing')
81
+ })
82
+ })
83
+
84
+ describe('renderFieldSnippet', () => {
85
+ it('renders a minimal optional field', () => {
86
+ expect(
87
+ renderFieldSnippet({ key: 'subtitle', label: 'Subtitle', type: 'text', required: false }),
88
+ ).toBe("subtitle: { type: 'text', label: 'Subtitle' },")
89
+ })
90
+
91
+ it('includes required, help text, and escapes quotes', () => {
92
+ const snippet = renderFieldSnippet({
93
+ key: 'clientName',
94
+ label: "Client's Name",
95
+ type: 'text',
96
+ required: true,
97
+ helpText: "Editor's hint",
98
+ })
99
+ expect(snippet).toContain("label: 'Client\\'s Name'")
100
+ expect(snippet).toContain('required: true')
101
+ expect(snippet).toContain("admin: { description: 'Editor\\'s hint' }")
102
+ })
103
+
104
+ it('seeds placeholder options for select and relationTo for relationship', () => {
105
+ expect(
106
+ renderFieldSnippet({ key: 'tone', label: 'Tone', type: 'select', required: false }),
107
+ ).toContain("options: [{ label: 'Option A', value: 'option-a' }]")
108
+ expect(
109
+ renderFieldSnippet({ key: 'owner', label: 'Owner', type: 'relationship', required: false }),
110
+ ).toContain("relationTo: 'posts'")
111
+ })
112
+ })
@@ -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
- * Regression for the post-list navigation bug: CollectionList must open
7
- * post-type rows in the section editor (`/posts/:type/:id/edit`), not the
8
- * generic `/:collection/:id` route which 404s when the slug is `posts`.
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('opens a post row in the section editor, not the generic route', () => {
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} />)
@@ -59,13 +59,17 @@ describe('Dashboard', () => {
59
59
  expect(screen.queryByText('Recent Activity')).toBeNull()
60
60
  })
61
61
 
62
- it('shows empty states when there is no content', () => {
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
- expect(screen.getByText('No activity yet')).toBeTruthy()
68
- expect(screen.getByText('No scheduled content')).toBeTruthy()
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', () => {
@@ -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 schema
7
- // drawer without a real backend.
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: vi.fn(async () => ({ data: {} })),
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 drawer from the Schema action', async () => {
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
- fireEvent.click(screen.getAllByRole('button', { name: 'Schema' })[0]!)
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
- // Drawer tabs are present.
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,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
+ })