@actuate-media/cms-admin 0.14.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (304) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +148 -2
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/posts-badges.test.d.ts +2 -0
  5. package/dist/__tests__/components/posts-badges.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/posts-badges.test.js +68 -0
  7. package/dist/__tests__/components/posts-badges.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-posts.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-posts.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-posts.test.js +162 -0
  11. package/dist/__tests__/layout/sidebar-posts.test.js.map +1 -0
  12. package/dist/__tests__/lib/page-editor-service.test.d.ts +2 -0
  13. package/dist/__tests__/lib/page-editor-service.test.d.ts.map +1 -0
  14. package/dist/__tests__/lib/page-editor-service.test.js +137 -0
  15. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -0
  16. package/dist/__tests__/lib/pages-service.test.d.ts +2 -0
  17. package/dist/__tests__/lib/pages-service.test.d.ts.map +1 -0
  18. package/dist/__tests__/lib/pages-service.test.js +140 -0
  19. package/dist/__tests__/lib/pages-service.test.js.map +1 -0
  20. package/dist/__tests__/lib/post-editor-service.test.d.ts +2 -0
  21. package/dist/__tests__/lib/post-editor-service.test.d.ts.map +1 -0
  22. package/dist/__tests__/lib/post-editor-service.test.js +185 -0
  23. package/dist/__tests__/lib/post-editor-service.test.js.map +1 -0
  24. package/dist/__tests__/lib/posts-service.test.d.ts +2 -0
  25. package/dist/__tests__/lib/posts-service.test.d.ts.map +1 -0
  26. package/dist/__tests__/lib/posts-service.test.js +244 -0
  27. package/dist/__tests__/lib/posts-service.test.js.map +1 -0
  28. package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
  29. package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
  30. package/dist/__tests__/views/posts-list-view.test.js +98 -0
  31. package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
  32. package/dist/actuate-admin.css +1 -1
  33. package/dist/components/pages/tag-badges.d.ts +21 -0
  34. package/dist/components/pages/tag-badges.d.ts.map +1 -0
  35. package/dist/components/pages/tag-badges.js +56 -0
  36. package/dist/components/pages/tag-badges.js.map +1 -0
  37. package/dist/components/posts/badges.d.ts +25 -0
  38. package/dist/components/posts/badges.d.ts.map +1 -0
  39. package/dist/components/posts/badges.js +123 -0
  40. package/dist/components/posts/badges.js.map +1 -0
  41. package/dist/components/posts/icons.d.ts +12 -0
  42. package/dist/components/posts/icons.d.ts.map +1 -0
  43. package/dist/components/posts/icons.js +26 -0
  44. package/dist/components/posts/icons.js.map +1 -0
  45. package/dist/layout/Header.d.ts +14 -1
  46. package/dist/layout/Header.d.ts.map +1 -1
  47. package/dist/layout/Header.js +10 -3
  48. package/dist/layout/Header.js.map +1 -1
  49. package/dist/layout/Layout.d.ts.map +1 -1
  50. package/dist/layout/Layout.js +78 -2
  51. package/dist/layout/Layout.js.map +1 -1
  52. package/dist/layout/Sidebar.d.ts +16 -0
  53. package/dist/layout/Sidebar.d.ts.map +1 -1
  54. package/dist/layout/Sidebar.js +176 -49
  55. package/dist/layout/Sidebar.js.map +1 -1
  56. package/dist/lib/page-editor-service.d.ts +45 -0
  57. package/dist/lib/page-editor-service.d.ts.map +1 -0
  58. package/dist/lib/page-editor-service.js +153 -0
  59. package/dist/lib/page-editor-service.js.map +1 -0
  60. package/dist/lib/pages-service.d.ts +143 -0
  61. package/dist/lib/pages-service.d.ts.map +1 -0
  62. package/dist/lib/pages-service.js +444 -0
  63. package/dist/lib/pages-service.js.map +1 -0
  64. package/dist/lib/post-editor-service.d.ts +57 -0
  65. package/dist/lib/post-editor-service.d.ts.map +1 -0
  66. package/dist/lib/post-editor-service.js +234 -0
  67. package/dist/lib/post-editor-service.js.map +1 -0
  68. package/dist/lib/posts-service.d.ts +73 -0
  69. package/dist/lib/posts-service.d.ts.map +1 -0
  70. package/dist/lib/posts-service.js +245 -0
  71. package/dist/lib/posts-service.js.map +1 -0
  72. package/dist/lib/preview-link.d.ts +32 -0
  73. package/dist/lib/preview-link.d.ts.map +1 -0
  74. package/dist/lib/preview-link.js +40 -0
  75. package/dist/lib/preview-link.js.map +1 -0
  76. package/dist/router/index.d.ts +1 -0
  77. package/dist/router/index.d.ts.map +1 -1
  78. package/dist/router/index.js +9 -1
  79. package/dist/router/index.js.map +1 -1
  80. package/dist/views/Dashboard.d.ts.map +1 -1
  81. package/dist/views/Dashboard.js +147 -62
  82. package/dist/views/Dashboard.js.map +1 -1
  83. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  84. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  85. package/dist/views/Pages/PageTagManager.js +145 -0
  86. package/dist/views/Pages/PageTagManager.js.map +1 -0
  87. package/dist/views/Pages/PagesListView.d.ts +18 -0
  88. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  89. package/dist/views/Pages/PagesListView.js +287 -0
  90. package/dist/views/Pages/PagesListView.js.map +1 -0
  91. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  92. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  93. package/dist/views/Posts/NewPostPicker.js +59 -0
  94. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  95. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  96. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  97. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  98. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  99. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  100. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  101. package/dist/views/Posts/PostTypesView.js +52 -0
  102. package/dist/views/Posts/PostTypesView.js.map +1 -0
  103. package/dist/views/Posts/PostsListView.d.ts +25 -0
  104. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  105. package/dist/views/Posts/PostsListView.js +239 -0
  106. package/dist/views/Posts/PostsListView.js.map +1 -0
  107. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  108. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  109. package/dist/views/Posts/post-type-presets.js +72 -0
  110. package/dist/views/Posts/post-type-presets.js.map +1 -0
  111. package/dist/views/page-builder/Widgets.d.ts +13 -0
  112. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  113. package/dist/views/page-builder/Widgets.js +15 -0
  114. package/dist/views/page-builder/Widgets.js.map +1 -0
  115. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  116. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  117. package/dist/views/page-editor/AddSectionModal.js +22 -0
  118. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  119. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  120. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  121. package/dist/views/page-editor/EditorCanvas.js +64 -0
  122. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  123. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  124. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  125. package/dist/views/page-editor/EditorTopBar.js +42 -0
  126. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  127. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  128. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  129. package/dist/views/page-editor/PagePreview.js +37 -0
  130. package/dist/views/page-editor/PagePreview.js.map +1 -0
  131. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  132. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  133. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  134. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  135. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  136. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  137. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  138. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  139. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  140. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  141. package/dist/views/page-editor/SectionInspector.js +98 -0
  142. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  143. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  144. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  145. package/dist/views/page-editor/SectionsPanel.js +43 -0
  146. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  147. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  148. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  149. package/dist/views/page-editor/ValidationSummary.js +10 -0
  150. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  151. package/dist/views/page-editor/section-icons.d.ts +4 -0
  152. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  153. package/dist/views/page-editor/section-icons.js +20 -0
  154. package/dist/views/page-editor/section-icons.js.map +1 -0
  155. package/dist/views/page-editor/section-types.d.ts +11 -0
  156. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  157. package/dist/views/page-editor/section-types.js +10 -0
  158. package/dist/views/page-editor/section-types.js.map +1 -0
  159. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  160. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  161. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  162. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  163. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  164. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  165. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  166. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  167. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  168. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  169. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  170. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  171. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  172. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  173. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  174. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  175. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  176. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  177. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  178. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  179. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  180. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  181. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  182. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  183. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  184. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  185. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  186. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  187. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  188. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  189. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  190. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  191. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  192. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  193. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  194. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  195. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  196. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  197. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  198. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  199. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  200. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  201. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  202. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  203. package/dist/views/page-editor/sections/index.d.ts +14 -0
  204. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  205. package/dist/views/page-editor/sections/index.js +12 -0
  206. package/dist/views/page-editor/sections/index.js.map +1 -0
  207. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  208. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  209. package/dist/views/page-editor/sections/parts.js +60 -0
  210. package/dist/views/page-editor/sections/parts.js.map +1 -0
  211. package/dist/views/page-editor/viewports.d.ts +14 -0
  212. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  213. package/dist/views/page-editor/viewports.js +12 -0
  214. package/dist/views/page-editor/viewports.js.map +1 -0
  215. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  216. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  217. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  218. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  219. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  220. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  221. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  222. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  223. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  224. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  225. package/dist/views/post-editor/PostHeader.js +27 -0
  226. package/dist/views/post-editor/PostHeader.js.map +1 -0
  227. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  228. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  229. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  230. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  231. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  232. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  233. package/dist/views/post-editor/PostPreview.js +53 -0
  234. package/dist/views/post-editor/PostPreview.js.map +1 -0
  235. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  236. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  237. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  238. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  239. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  240. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  241. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  242. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  243. package/package.json +2 -2
  244. package/src/AdminRoot.tsx +213 -2
  245. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  246. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  247. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  248. package/src/__tests__/lib/pages-service.test.ts +157 -0
  249. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  250. package/src/__tests__/lib/posts-service.test.ts +279 -0
  251. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  252. package/src/components/pages/tag-badges.tsx +104 -0
  253. package/src/components/posts/badges.tsx +170 -0
  254. package/src/components/posts/icons.ts +40 -0
  255. package/src/layout/Header.tsx +90 -43
  256. package/src/layout/Layout.tsx +166 -1
  257. package/src/layout/Sidebar.tsx +287 -101
  258. package/src/lib/page-editor-service.ts +233 -0
  259. package/src/lib/pages-service.ts +599 -0
  260. package/src/lib/post-editor-service.ts +342 -0
  261. package/src/lib/posts-service.ts +366 -0
  262. package/src/lib/preview-link.ts +63 -0
  263. package/src/router/index.ts +10 -1
  264. package/src/views/Dashboard.tsx +270 -131
  265. package/src/views/Pages/PageTagManager.tsx +436 -0
  266. package/src/views/Pages/PagesListView.tsx +944 -0
  267. package/src/views/Posts/NewPostPicker.tsx +147 -0
  268. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  269. package/src/views/Posts/PostTypesView.tsx +194 -0
  270. package/src/views/Posts/PostsListView.tsx +869 -0
  271. package/src/views/Posts/post-type-presets.ts +105 -0
  272. package/src/views/page-builder/Widgets.tsx +44 -0
  273. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  274. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  275. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  276. package/src/views/page-editor/PagePreview.tsx +96 -0
  277. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  278. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  279. package/src/views/page-editor/SectionInspector.tsx +599 -0
  280. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  281. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  282. package/src/views/page-editor/section-icons.ts +33 -0
  283. package/src/views/page-editor/section-types.ts +27 -0
  284. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  285. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  286. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  287. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  288. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  289. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  290. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  291. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  292. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  293. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  294. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  295. package/src/views/page-editor/sections/index.ts +13 -0
  296. package/src/views/page-editor/sections/parts.tsx +113 -0
  297. package/src/views/page-editor/viewports.ts +25 -0
  298. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  299. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  300. package/src/views/post-editor/PostHeader.tsx +87 -0
  301. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  302. package/src/views/post-editor/PostPreview.tsx +114 -0
  303. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  304. package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
@@ -0,0 +1,179 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { buildNavItems } from '../../layout/Sidebar.js'
3
+
4
+ /**
5
+ * Regression coverage for the Posts area sidebar grouping.
6
+ *
7
+ * The reference design shows a single "Posts" parent with two fixed
8
+ * children — All Posts and Post Types — even when the config defines
9
+ * multiple post-type collections (Blog, Case Studies, News, ...). That
10
+ * collapsing is fragile (it's tempting to enumerate each collection
11
+ * as its own sidebar entry) so we lock the contract with explicit
12
+ * tests.
13
+ */
14
+
15
+ describe('buildNavItems', () => {
16
+ it('returns the default tree when no collections are provided', () => {
17
+ const items = buildNavItems({})
18
+ const posts = items.find((i) => i.path === '/posts')
19
+ expect(posts).toBeDefined()
20
+ expect(posts?.children?.map((c) => c.path)).toEqual(['/posts', '/posts/types'])
21
+ // Pages no longer carries children — Saved Sections / Templates moved
22
+ // under the dedicated Templates parent.
23
+ const pages = items.find((i) => i.path === '/pages')
24
+ expect(pages?.children ?? []).toEqual([])
25
+ // Templates is its own item with Saved Sections + Widgets.
26
+ const templates = items.find((i) => i.path === '/page-templates')
27
+ expect(templates?.children?.map((c) => c.path)).toEqual(['/saved-sections', '/widgets'])
28
+ })
29
+
30
+ it('exposes Templates as a top-level Content item with Saved Sections + Widgets', () => {
31
+ const items = buildNavItems({
32
+ collections: {
33
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
34
+ },
35
+ })
36
+ const templates = items.find((i) => i.path === '/page-templates')
37
+ expect(templates).toBeDefined()
38
+ expect(templates?.group).toBe('Content')
39
+ expect(templates?.children?.map((c) => c.label)).toEqual(['Saved Sections', 'Widgets'])
40
+ // Templates is NOT nested under Pages.
41
+ const pages = items.find((i) => i.path === '/pages')
42
+ expect(pages?.children ?? []).toEqual([])
43
+ })
44
+
45
+ it('collapses every type:post collection under a single Posts parent', () => {
46
+ const items = buildNavItems({
47
+ collections: {
48
+ blog: { slug: 'blog', labels: { singular: 'Blog Post', plural: 'Blog' }, type: 'post' },
49
+ 'case-studies': {
50
+ slug: 'case-studies',
51
+ labels: { singular: 'Case Study', plural: 'Case Studies' },
52
+ type: 'post',
53
+ },
54
+ news: { slug: 'news', labels: { singular: 'News Item', plural: 'News' }, type: 'post' },
55
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
56
+ },
57
+ })
58
+
59
+ // Exactly one /posts entry, regardless of how many post types exist.
60
+ const postEntries = items.filter((i) => i.path === '/posts')
61
+ expect(postEntries).toHaveLength(1)
62
+
63
+ // Children are still the fixed pair — the per-type collections
64
+ // are NOT enumerated as sidebar siblings.
65
+ const children = postEntries[0]?.children?.map((c) => c.path)
66
+ expect(children).toEqual(['/posts', '/posts/types'])
67
+
68
+ // No per-type slug ended up as a top-level sidebar item.
69
+ const slugPaths = items.map((i) => i.path)
70
+ for (const slug of ['blog', 'case-studies', 'news']) {
71
+ expect(slugPaths).not.toContain(`/${slug}`)
72
+ }
73
+ })
74
+
75
+ it('treats admin.group=Posts (case-insensitive) as a synonym for type=post', () => {
76
+ const items = buildNavItems({
77
+ collections: {
78
+ reviews: {
79
+ slug: 'reviews',
80
+ labels: { singular: 'Review', plural: 'Reviews' },
81
+ // Intentionally NOT type:'post' — relying on the group marker.
82
+ admin: { group: 'posts' },
83
+ },
84
+ },
85
+ })
86
+ expect(items.some((i) => i.path === '/posts')).toBe(true)
87
+ expect(items.some((i) => i.path === '/reviews')).toBe(false)
88
+ })
89
+
90
+ it('omits the Posts parent entirely when no post-type collections exist', () => {
91
+ const items = buildNavItems({
92
+ collections: {
93
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
94
+ },
95
+ })
96
+ expect(items.some((i) => i.path === '/posts')).toBe(false)
97
+ })
98
+
99
+ it('places Pages above Posts, both under the CONTENT group', () => {
100
+ const items = buildNavItems({
101
+ collections: {
102
+ blog: { slug: 'blog', labels: { singular: 'Blog Post', plural: 'Blog' }, type: 'post' },
103
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
104
+ },
105
+ })
106
+
107
+ const pagesIdx = items.findIndex((i) => i.path === '/pages')
108
+ const postsIdx = items.findIndex((i) => i.path === '/posts')
109
+ const mediaIdx = items.findIndex((i) => i.path === '/media')
110
+
111
+ // Pages comes before Posts, and both come before the static
112
+ // content destinations.
113
+ expect(pagesIdx).toBeGreaterThanOrEqual(0)
114
+ expect(pagesIdx).toBeLessThan(postsIdx)
115
+ expect(postsIdx).toBeLessThan(mediaIdx)
116
+
117
+ // Both are tagged Content so the single CONTENT header sits above
118
+ // Pages (the first Content item in the tree).
119
+ expect(items[pagesIdx]?.group).toBe('Content')
120
+ expect(items[postsIdx]?.group).toBe('Content')
121
+
122
+ // The first item carrying group 'Content' is Pages — i.e. the
123
+ // CONTENT header renders directly above it.
124
+ const firstContent = items.find((i) => i.group === 'Content')
125
+ expect(firstContent?.path).toBe('/pages')
126
+ })
127
+
128
+ it('defaults: Pages is the first CONTENT item, above Posts', () => {
129
+ const items = buildNavItems({})
130
+ const pagesIdx = items.findIndex((i) => i.path === '/pages')
131
+ const postsIdx = items.findIndex((i) => i.path === '/posts')
132
+ expect(items[pagesIdx]?.group).toBe('Content')
133
+ expect(items[postsIdx]?.group).toBe('Content')
134
+ expect(pagesIdx).toBeLessThan(postsIdx)
135
+ expect(items.find((i) => i.group === 'Content')?.path).toBe('/pages')
136
+ })
137
+
138
+ it('renders an admin.group=Content collection (Menus) as its own Content item', () => {
139
+ const items = buildNavItems({
140
+ collections: {
141
+ pages: { slug: 'pages', labels: { singular: 'Page', plural: 'Pages' }, type: 'page' },
142
+ navigations: {
143
+ slug: 'navigations',
144
+ labels: { singular: 'Menu', plural: 'Menus' },
145
+ admin: { group: 'Content', icon: 'navigation' },
146
+ },
147
+ },
148
+ })
149
+
150
+ const menus = items.find((i) => i.path === '/navigations')
151
+ expect(menus).toBeDefined()
152
+ expect(menus?.label).toBe('Menus')
153
+ expect(menus?.group).toBe('Content')
154
+ // It is a flat Content entry, not a child of Posts/Templates/Pages.
155
+ expect(menus?.children).toBeUndefined()
156
+ // Lands after the static Content destinations, before Settings.
157
+ const menusIdx = items.findIndex((i) => i.path === '/navigations')
158
+ const scriptIdx = items.findIndex((i) => i.path === '/script-tags')
159
+ const usersIdx = items.findIndex((i) => i.path === '/users')
160
+ expect(menusIdx).toBeGreaterThan(scriptIdx)
161
+ expect(menusIdx).toBeLessThan(usersIdx)
162
+ })
163
+
164
+ it('keeps standard Content/Settings entries after collections', () => {
165
+ const items = buildNavItems({
166
+ collections: {
167
+ blog: { slug: 'blog', labels: { singular: 'Blog Post', plural: 'Blog' }, type: 'post' },
168
+ },
169
+ })
170
+ const paths = items.map((i) => i.path)
171
+ expect(paths).toContain('/media')
172
+ expect(paths).toContain('/forms')
173
+ expect(paths).toContain('/seo')
174
+ expect(paths).toContain('/script-tags')
175
+ expect(paths).toContain('/users')
176
+ expect(paths).toContain('/api-keys')
177
+ expect(paths).toContain('/settings')
178
+ })
179
+ })
@@ -0,0 +1,173 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ addSection,
4
+ createSection,
5
+ duplicateSection,
6
+ emptyPage,
7
+ moveSection,
8
+ removeSection,
9
+ reorderSections,
10
+ toggleSectionVisibility,
11
+ updateSectionContent,
12
+ updateSectionMeta,
13
+ updateSectionSettings,
14
+ validatePage,
15
+ type EditorPage,
16
+ type PageSection,
17
+ } from '../../lib/page-editor-service.js'
18
+ import { SECTION_TYPES, SECTION_TYPE_LIST } from '../../views/page-editor/section-types.js'
19
+
20
+ function basePage(sections: PageSection[]): EditorPage {
21
+ return { ...emptyPage(), title: 'About', slug: 'about', path: '/about', sections }
22
+ }
23
+
24
+ describe('createSection', () => {
25
+ it('seeds content + settings from the registry and a unique id', () => {
26
+ const a = createSection('hero')
27
+ const b = createSection('hero')
28
+ expect(a.sectionType).toBe('hero')
29
+ expect(a.name).toBe(SECTION_TYPES.hero.name)
30
+ expect(a.visible).toBe(true)
31
+ expect(a.id).not.toBe(b.id)
32
+ expect(a.content.heading).toBe(SECTION_TYPES.hero.defaultContent.heading)
33
+ })
34
+
35
+ it('deep-clones defaults so sections never share references', () => {
36
+ const a = createSection('stats')
37
+ const b = createSection('stats')
38
+ ;(a.content.stats as unknown[]).push({ value: 'x' })
39
+ expect((b.content.stats as unknown[]).length).toBe(
40
+ (SECTION_TYPES.stats.defaultContent.stats as unknown[]).length,
41
+ )
42
+ })
43
+ })
44
+
45
+ describe('section array helpers', () => {
46
+ const s1 = createSection('hero')
47
+ const s2 = createSection('text')
48
+ const s3 = createSection('cta')
49
+ const list = [s1, s2, s3]
50
+
51
+ it('addSection appends by default and inserts at an index', () => {
52
+ const extra = createSection('stats')
53
+ expect(addSection(list, extra).map((s) => s.id)).toEqual([s1.id, s2.id, s3.id, extra.id])
54
+ expect(addSection(list, extra, 1).map((s) => s.id)).toEqual([s1.id, extra.id, s2.id, s3.id])
55
+ })
56
+
57
+ it('removeSection drops the matching id', () => {
58
+ expect(removeSection(list, s2.id).map((s) => s.id)).toEqual([s1.id, s3.id])
59
+ })
60
+
61
+ it('moveSection relocates and clamps', () => {
62
+ expect(moveSection(list, s1.id, 2).map((s) => s.id)).toEqual([s2.id, s3.id, s1.id])
63
+ expect(moveSection(list, s1.id, 99).map((s) => s.id)).toEqual([s2.id, s3.id, s1.id])
64
+ })
65
+
66
+ it('reorderSections respects explicit order and never drops sections', () => {
67
+ expect(reorderSections(list, [s3.id, s1.id, s2.id]).map((s) => s.id)).toEqual([
68
+ s3.id,
69
+ s1.id,
70
+ s2.id,
71
+ ])
72
+ // missing ids are appended defensively rather than lost
73
+ expect(reorderSections(list, [s2.id]).map((s) => s.id)).toEqual([s2.id, s1.id, s3.id])
74
+ })
75
+
76
+ it('duplicateSection inserts a new-id copy right after the original', () => {
77
+ const out = duplicateSection(list, s2.id)
78
+ expect(out).toHaveLength(4)
79
+ expect(out[2]!.id).not.toBe(s2.id)
80
+ expect(out[2]!.name).toBe(`${s2.name} (Copy)`)
81
+ expect(out[2]!.sectionType).toBe(s2.sectionType)
82
+ })
83
+
84
+ it('toggleSectionVisibility flips only the target', () => {
85
+ const out = toggleSectionVisibility(list, s1.id)
86
+ expect(out.find((s) => s.id === s1.id)!.visible).toBe(false)
87
+ expect(out.find((s) => s.id === s2.id)!.visible).toBe(true)
88
+ })
89
+
90
+ it('updateSectionContent / settings / meta patch immutably', () => {
91
+ const c = updateSectionContent(list, s1.id, { heading: 'New' })
92
+ expect(c.find((s) => s.id === s1.id)!.content.heading).toBe('New')
93
+ expect(list.find((s) => s.id === s1.id)!.content.heading).not.toBe('New')
94
+
95
+ const st = updateSectionSettings(list, s1.id, { paddingY: 'sm' })
96
+ expect(st.find((s) => s.id === s1.id)!.settings.paddingY).toBe('sm')
97
+
98
+ const m = updateSectionMeta(list, s1.id, { name: 'Renamed' })
99
+ expect(m.find((s) => s.id === s1.id)!.name).toBe('Renamed')
100
+ })
101
+ })
102
+
103
+ describe('validatePage', () => {
104
+ it('requires title and path', () => {
105
+ const page = basePage([])
106
+ page.title = ''
107
+ page.path = ''
108
+ const { errors } = validatePage(page, false)
109
+ expect(errors).toContain('Page title is required.')
110
+ expect(errors).toContain('Page path is required.')
111
+ })
112
+
113
+ it('requires at least one visible section to publish', () => {
114
+ const hidden = { ...createSection('hero'), visible: false }
115
+ const { errors } = validatePage(basePage([hidden]), true)
116
+ expect(errors.some((e) => e.includes('at least one visible section'))).toBe(true)
117
+ })
118
+
119
+ it('flags missing required section fields on publish', () => {
120
+ const hero = createSection('hero')
121
+ hero.content.heading = '' // required
122
+ const { errors } = validatePage(basePage([hero]), true)
123
+ expect(errors.some((e) => e.includes('Heading'))).toBe(true)
124
+ })
125
+
126
+ it('flags invalid button URLs and missing image alt on publish', () => {
127
+ const feature = createSection('feature')
128
+ feature.content.heading = 'Has heading'
129
+ feature.content.buttonLabel = 'Learn more'
130
+ feature.content.buttonUrl = 'not a url'
131
+ feature.content.imageUrl = 'https://x/img.png'
132
+ feature.content.imageAlt = ''
133
+ const { errors } = validatePage(basePage([feature]), true)
134
+ expect(errors.some((e) => e.includes('not a valid URL'))).toBe(true)
135
+ expect(errors.some((e) => e.includes('alt text'))).toBe(true)
136
+ })
137
+
138
+ it('accepts a valid page and warns on missing SEO', () => {
139
+ const hero = createSection('hero')
140
+ const { errors, warnings } = validatePage(basePage([hero]), true)
141
+ expect(errors).toEqual([])
142
+ expect(warnings.length).toBeGreaterThan(0)
143
+ })
144
+
145
+ it('does not run publish-only rules for draft saves', () => {
146
+ const { errors } = validatePage(basePage([]), false)
147
+ expect(errors.some((e) => e.includes('visible section'))).toBe(false)
148
+ })
149
+ })
150
+
151
+ describe('section type registry', () => {
152
+ it('every listed type has a renderable id, defaults, fields, and a scope', () => {
153
+ const renderable = new Set([
154
+ 'hero',
155
+ 'text',
156
+ 'feature',
157
+ 'stats',
158
+ 'cta',
159
+ 'article-body',
160
+ 'quote',
161
+ 'author-bio',
162
+ 'related-posts',
163
+ 'gallery',
164
+ ])
165
+ expect(SECTION_TYPE_LIST.length).toBe(renderable.size)
166
+ for (const def of SECTION_TYPE_LIST) {
167
+ expect(renderable.has(def.id)).toBe(true)
168
+ expect(def.fields.length).toBeGreaterThan(0)
169
+ expect(def.defaultContent).toBeTruthy()
170
+ expect(def.scope.length).toBeGreaterThan(0)
171
+ }
172
+ })
173
+ })
@@ -0,0 +1,157 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { applyPagesQuery, groupPages, type Page, type PageTag } from '../../lib/pages-service.js'
3
+
4
+ /**
5
+ * Unit coverage for the Pages filtering/grouping logic. These are the
6
+ * pure functions the PagesListView depends on — kept network-free so the
7
+ * search/tag/status/sort/group contracts are locked independent of the
8
+ * REST layer.
9
+ */
10
+
11
+ function page(partial: Partial<Page> & Pick<Page, 'id' | 'title'>): Page {
12
+ return {
13
+ id: partial.id,
14
+ title: partial.title,
15
+ path: partial.path ?? `/${partial.title.toLowerCase()}`,
16
+ slug: partial.slug ?? partial.title.toLowerCase(),
17
+ parentPageId: null,
18
+ tags: partial.tags ?? [],
19
+ primaryTagId: partial.primaryTagId ?? partial.tags?.[0] ?? null,
20
+ authorId: partial.authorId ?? 'u1',
21
+ authorName: partial.authorName ?? 'Maria K.',
22
+ authorInitials: 'MK',
23
+ status: partial.status ?? 'PUBLISHED',
24
+ publishDate: null,
25
+ scheduledDate: null,
26
+ lastModified: partial.lastModified ?? '2026-05-01',
27
+ seoScore: partial.seoScore ?? null,
28
+ seoTitle: null,
29
+ seoDescription: null,
30
+ templateId: null,
31
+ isHomepage: partial.isHomepage ?? false,
32
+ isSystemPage: false,
33
+ createdAt: '2026-01-01',
34
+ updatedAt: partial.lastModified ?? '2026-05-01',
35
+ }
36
+ }
37
+
38
+ const TAGS: PageTag[] = [
39
+ {
40
+ id: 'core',
41
+ docId: 'd1',
42
+ name: 'Core',
43
+ slug: 'core',
44
+ description: null,
45
+ color: 'purple',
46
+ pageCount: 0,
47
+ sortOrder: 0,
48
+ active: true,
49
+ createdAt: null,
50
+ updatedAt: null,
51
+ },
52
+ {
53
+ id: 'services',
54
+ docId: 'd2',
55
+ name: 'Services',
56
+ slug: 'services',
57
+ description: null,
58
+ color: 'green',
59
+ pageCount: 0,
60
+ sortOrder: 1,
61
+ active: true,
62
+ createdAt: null,
63
+ updatedAt: null,
64
+ },
65
+ ]
66
+
67
+ const PAGES: Page[] = [
68
+ page({ id: '1', title: 'About', tags: ['core'], status: 'PUBLISHED', seoScore: 88 }),
69
+ page({
70
+ id: '2',
71
+ title: 'Services',
72
+ tags: ['core', 'services'],
73
+ status: 'PUBLISHED',
74
+ seoScore: 85,
75
+ }),
76
+ page({
77
+ id: '3',
78
+ title: 'SEO Services',
79
+ tags: ['services'],
80
+ status: 'DRAFT',
81
+ seoScore: 44,
82
+ authorId: 'u2',
83
+ authorName: 'Sarah M.',
84
+ }),
85
+ page({ id: '4', title: 'Home', tags: [], status: 'PUBLISHED', isHomepage: true, seoScore: 92 }),
86
+ ]
87
+
88
+ describe('applyPagesQuery', () => {
89
+ it('defaults to title ascending', () => {
90
+ const out = applyPagesQuery(PAGES, {})
91
+ expect(out.map((p) => p.title)).toEqual(['About', 'Home', 'SEO Services', 'Services'])
92
+ })
93
+
94
+ it('filters by status', () => {
95
+ const out = applyPagesQuery(PAGES, { status: 'DRAFT' })
96
+ expect(out.map((p) => p.id)).toEqual(['3'])
97
+ })
98
+
99
+ it('filters by tag with OR semantics', () => {
100
+ const out = applyPagesQuery(PAGES, { tagIds: ['services'] })
101
+ expect(out.map((p) => p.id).sort()).toEqual(['2', '3'])
102
+ })
103
+
104
+ it('matches multiple selected tags (union)', () => {
105
+ const out = applyPagesQuery(PAGES, { tagIds: ['core', 'services'] })
106
+ expect(out.map((p) => p.id).sort()).toEqual(['1', '2', '3'])
107
+ })
108
+
109
+ it('searches title, path, and tags', () => {
110
+ expect(applyPagesQuery(PAGES, { search: 'seo' }).map((p) => p.id)).toEqual(['3'])
111
+ expect(
112
+ applyPagesQuery(PAGES, { search: 'core' })
113
+ .map((p) => p.id)
114
+ .sort(),
115
+ ).toEqual(['1', '2'])
116
+ })
117
+
118
+ it('sorts by seo descending', () => {
119
+ const out = applyPagesQuery(PAGES, { sortBy: 'seo', sortDirection: 'desc' })
120
+ expect(out[0]?.id).toBe('4') // Home, 92
121
+ })
122
+ })
123
+
124
+ describe('groupPages', () => {
125
+ it('returns a single bucket for groupBy none', () => {
126
+ const groups = groupPages(PAGES, 'none', TAGS)
127
+ expect(groups).toHaveLength(1)
128
+ expect(groups[0]?.pages).toHaveLength(4)
129
+ })
130
+
131
+ it('groups by primary tag and bucket-labels untagged pages', () => {
132
+ const groups = groupPages(PAGES, 'tag', TAGS)
133
+ const byKey = new Map(groups.map((g) => [g.key, g]))
134
+ // About + Services have primary tag 'core'; SEO Services 'services';
135
+ // Home has no tags → Untagged.
136
+ expect(
137
+ byKey
138
+ .get('core')
139
+ ?.pages.map((p) => p.id)
140
+ .sort(),
141
+ ).toEqual(['1', '2'])
142
+ expect(byKey.get('services')?.pages.map((p) => p.id)).toEqual(['3'])
143
+ expect(byKey.get('__untagged')?.pages.map((p) => p.id)).toEqual(['4'])
144
+ })
145
+
146
+ it('never duplicates a page across tag groups', () => {
147
+ const groups = groupPages(PAGES, 'tag', TAGS)
148
+ const total = groups.reduce((n, g) => n + g.pages.length, 0)
149
+ expect(total).toBe(PAGES.length)
150
+ })
151
+
152
+ it('groups by status', () => {
153
+ const groups = groupPages(PAGES, 'status', TAGS)
154
+ const published = groups.find((g) => g.key === 'PUBLISHED')
155
+ expect(published?.pages).toHaveLength(3)
156
+ })
157
+ })