@actuate-media/cms-admin 0.15.0 → 0.16.1

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 (308) 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__/lib/useApiData.test.d.ts +2 -0
  29. package/dist/__tests__/lib/useApiData.test.d.ts.map +1 -0
  30. package/dist/__tests__/lib/useApiData.test.js +84 -0
  31. package/dist/__tests__/lib/useApiData.test.js.map +1 -0
  32. package/dist/__tests__/views/dashboard.test.d.ts +2 -0
  33. package/dist/__tests__/views/dashboard.test.d.ts.map +1 -0
  34. package/dist/__tests__/views/dashboard.test.js +138 -0
  35. package/dist/__tests__/views/dashboard.test.js.map +1 -0
  36. package/dist/__tests__/views/posts-list-view.test.d.ts +2 -0
  37. package/dist/__tests__/views/posts-list-view.test.d.ts.map +1 -0
  38. package/dist/__tests__/views/posts-list-view.test.js +98 -0
  39. package/dist/__tests__/views/posts-list-view.test.js.map +1 -0
  40. package/dist/actuate-admin.css +1 -1
  41. package/dist/components/pages/tag-badges.d.ts +21 -0
  42. package/dist/components/pages/tag-badges.d.ts.map +1 -0
  43. package/dist/components/pages/tag-badges.js +56 -0
  44. package/dist/components/pages/tag-badges.js.map +1 -0
  45. package/dist/components/posts/badges.d.ts +25 -0
  46. package/dist/components/posts/badges.d.ts.map +1 -0
  47. package/dist/components/posts/badges.js +123 -0
  48. package/dist/components/posts/badges.js.map +1 -0
  49. package/dist/components/posts/icons.d.ts +12 -0
  50. package/dist/components/posts/icons.d.ts.map +1 -0
  51. package/dist/components/posts/icons.js +26 -0
  52. package/dist/components/posts/icons.js.map +1 -0
  53. package/dist/layout/Layout.js +36 -8
  54. package/dist/layout/Layout.js.map +1 -1
  55. package/dist/layout/Sidebar.d.ts +9 -0
  56. package/dist/layout/Sidebar.d.ts.map +1 -1
  57. package/dist/layout/Sidebar.js +100 -48
  58. package/dist/layout/Sidebar.js.map +1 -1
  59. package/dist/lib/page-editor-service.d.ts +45 -0
  60. package/dist/lib/page-editor-service.d.ts.map +1 -0
  61. package/dist/lib/page-editor-service.js +153 -0
  62. package/dist/lib/page-editor-service.js.map +1 -0
  63. package/dist/lib/pages-service.d.ts +143 -0
  64. package/dist/lib/pages-service.d.ts.map +1 -0
  65. package/dist/lib/pages-service.js +444 -0
  66. package/dist/lib/pages-service.js.map +1 -0
  67. package/dist/lib/post-editor-service.d.ts +57 -0
  68. package/dist/lib/post-editor-service.d.ts.map +1 -0
  69. package/dist/lib/post-editor-service.js +234 -0
  70. package/dist/lib/post-editor-service.js.map +1 -0
  71. package/dist/lib/posts-service.d.ts +73 -0
  72. package/dist/lib/posts-service.d.ts.map +1 -0
  73. package/dist/lib/posts-service.js +245 -0
  74. package/dist/lib/posts-service.js.map +1 -0
  75. package/dist/lib/preview-link.d.ts +32 -0
  76. package/dist/lib/preview-link.d.ts.map +1 -0
  77. package/dist/lib/preview-link.js +40 -0
  78. package/dist/lib/preview-link.js.map +1 -0
  79. package/dist/router/index.d.ts +1 -0
  80. package/dist/router/index.d.ts.map +1 -1
  81. package/dist/router/index.js +9 -1
  82. package/dist/router/index.js.map +1 -1
  83. package/dist/views/Dashboard.d.ts.map +1 -1
  84. package/dist/views/Dashboard.js +64 -20
  85. package/dist/views/Dashboard.js.map +1 -1
  86. package/dist/views/Pages/PageTagManager.d.ts +14 -0
  87. package/dist/views/Pages/PageTagManager.d.ts.map +1 -0
  88. package/dist/views/Pages/PageTagManager.js +145 -0
  89. package/dist/views/Pages/PageTagManager.js.map +1 -0
  90. package/dist/views/Pages/PagesListView.d.ts +18 -0
  91. package/dist/views/Pages/PagesListView.d.ts.map +1 -0
  92. package/dist/views/Pages/PagesListView.js +287 -0
  93. package/dist/views/Pages/PagesListView.js.map +1 -0
  94. package/dist/views/Posts/NewPostPicker.d.ts +16 -0
  95. package/dist/views/Posts/NewPostPicker.d.ts.map +1 -0
  96. package/dist/views/Posts/NewPostPicker.js +59 -0
  97. package/dist/views/Posts/NewPostPicker.js.map +1 -0
  98. package/dist/views/Posts/NewPostTypeInstructions.d.ts +18 -0
  99. package/dist/views/Posts/NewPostTypeInstructions.d.ts.map +1 -0
  100. package/dist/views/Posts/NewPostTypeInstructions.js +147 -0
  101. package/dist/views/Posts/NewPostTypeInstructions.js.map +1 -0
  102. package/dist/views/Posts/PostTypesView.d.ts +26 -0
  103. package/dist/views/Posts/PostTypesView.d.ts.map +1 -0
  104. package/dist/views/Posts/PostTypesView.js +52 -0
  105. package/dist/views/Posts/PostTypesView.js.map +1 -0
  106. package/dist/views/Posts/PostsListView.d.ts +25 -0
  107. package/dist/views/Posts/PostsListView.d.ts.map +1 -0
  108. package/dist/views/Posts/PostsListView.js +239 -0
  109. package/dist/views/Posts/PostsListView.js.map +1 -0
  110. package/dist/views/Posts/post-type-presets.d.ts +32 -0
  111. package/dist/views/Posts/post-type-presets.d.ts.map +1 -0
  112. package/dist/views/Posts/post-type-presets.js +72 -0
  113. package/dist/views/Posts/post-type-presets.js.map +1 -0
  114. package/dist/views/page-builder/Widgets.d.ts +13 -0
  115. package/dist/views/page-builder/Widgets.d.ts.map +1 -0
  116. package/dist/views/page-builder/Widgets.js +15 -0
  117. package/dist/views/page-builder/Widgets.js.map +1 -0
  118. package/dist/views/page-editor/AddSectionModal.d.ts +20 -0
  119. package/dist/views/page-editor/AddSectionModal.d.ts.map +1 -0
  120. package/dist/views/page-editor/AddSectionModal.js +22 -0
  121. package/dist/views/page-editor/AddSectionModal.js.map +1 -0
  122. package/dist/views/page-editor/EditorCanvas.d.ts +18 -0
  123. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -0
  124. package/dist/views/page-editor/EditorCanvas.js +64 -0
  125. package/dist/views/page-editor/EditorCanvas.js.map +1 -0
  126. package/dist/views/page-editor/EditorTopBar.d.ts +30 -0
  127. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -0
  128. package/dist/views/page-editor/EditorTopBar.js +42 -0
  129. package/dist/views/page-editor/EditorTopBar.js.map +1 -0
  130. package/dist/views/page-editor/PagePreview.d.ts +13 -0
  131. package/dist/views/page-editor/PagePreview.d.ts.map +1 -0
  132. package/dist/views/page-editor/PagePreview.js +37 -0
  133. package/dist/views/page-editor/PagePreview.js.map +1 -0
  134. package/dist/views/page-editor/PageSectionEditor.d.ts +16 -0
  135. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -0
  136. package/dist/views/page-editor/PageSectionEditor.js +272 -0
  137. package/dist/views/page-editor/PageSectionEditor.js.map +1 -0
  138. package/dist/views/page-editor/PageSettingsModal.d.ts +12 -0
  139. package/dist/views/page-editor/PageSettingsModal.d.ts.map +1 -0
  140. package/dist/views/page-editor/PageSettingsModal.js +52 -0
  141. package/dist/views/page-editor/PageSettingsModal.js.map +1 -0
  142. package/dist/views/page-editor/SectionInspector.d.ts +17 -0
  143. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -0
  144. package/dist/views/page-editor/SectionInspector.js +98 -0
  145. package/dist/views/page-editor/SectionInspector.js.map +1 -0
  146. package/dist/views/page-editor/SectionsPanel.d.ts +15 -0
  147. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -0
  148. package/dist/views/page-editor/SectionsPanel.js +43 -0
  149. package/dist/views/page-editor/SectionsPanel.js.map +1 -0
  150. package/dist/views/page-editor/ValidationSummary.d.ts +9 -0
  151. package/dist/views/page-editor/ValidationSummary.d.ts.map +1 -0
  152. package/dist/views/page-editor/ValidationSummary.js +10 -0
  153. package/dist/views/page-editor/ValidationSummary.js.map +1 -0
  154. package/dist/views/page-editor/section-icons.d.ts +4 -0
  155. package/dist/views/page-editor/section-icons.d.ts.map +1 -0
  156. package/dist/views/page-editor/section-icons.js +20 -0
  157. package/dist/views/page-editor/section-icons.js.map +1 -0
  158. package/dist/views/page-editor/section-types.d.ts +11 -0
  159. package/dist/views/page-editor/section-types.d.ts.map +1 -0
  160. package/dist/views/page-editor/section-types.js +10 -0
  161. package/dist/views/page-editor/section-types.js.map +1 -0
  162. package/dist/views/page-editor/sections/ArticleBodySection.d.ts +13 -0
  163. package/dist/views/page-editor/sections/ArticleBodySection.d.ts.map +1 -0
  164. package/dist/views/page-editor/sections/ArticleBodySection.js +23 -0
  165. package/dist/views/page-editor/sections/ArticleBodySection.js.map +1 -0
  166. package/dist/views/page-editor/sections/AuthorBioSection.d.ts +8 -0
  167. package/dist/views/page-editor/sections/AuthorBioSection.d.ts.map +1 -0
  168. package/dist/views/page-editor/sections/AuthorBioSection.js +21 -0
  169. package/dist/views/page-editor/sections/AuthorBioSection.js.map +1 -0
  170. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts +5 -0
  171. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -0
  172. package/dist/views/page-editor/sections/ByTheNumbersSection.js +10 -0
  173. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -0
  174. package/dist/views/page-editor/sections/CtaBannerSection.d.ts +5 -0
  175. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -0
  176. package/dist/views/page-editor/sections/CtaBannerSection.js +10 -0
  177. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -0
  178. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts +5 -0
  179. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -0
  180. package/dist/views/page-editor/sections/FeatureHighlightSection.js +18 -0
  181. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -0
  182. package/dist/views/page-editor/sections/GallerySection.d.ts +6 -0
  183. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -0
  184. package/dist/views/page-editor/sections/GallerySection.js +17 -0
  185. package/dist/views/page-editor/sections/GallerySection.js.map +1 -0
  186. package/dist/views/page-editor/sections/HeroBannerSection.d.ts +5 -0
  187. package/dist/views/page-editor/sections/HeroBannerSection.d.ts.map +1 -0
  188. package/dist/views/page-editor/sections/HeroBannerSection.js +11 -0
  189. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -0
  190. package/dist/views/page-editor/sections/MissionSection.d.ts +6 -0
  191. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -0
  192. package/dist/views/page-editor/sections/MissionSection.js +12 -0
  193. package/dist/views/page-editor/sections/MissionSection.js.map +1 -0
  194. package/dist/views/page-editor/sections/PullQuoteSection.d.ts +6 -0
  195. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -0
  196. package/dist/views/page-editor/sections/PullQuoteSection.js +13 -0
  197. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -0
  198. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts +12 -0
  199. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -0
  200. package/dist/views/page-editor/sections/RelatedPostsSection.js +23 -0
  201. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -0
  202. package/dist/views/page-editor/sections/SectionRenderer.d.ts +22 -0
  203. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -0
  204. package/dist/views/page-editor/sections/SectionRenderer.js +52 -0
  205. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -0
  206. package/dist/views/page-editor/sections/index.d.ts +14 -0
  207. package/dist/views/page-editor/sections/index.d.ts.map +1 -0
  208. package/dist/views/page-editor/sections/index.js +12 -0
  209. package/dist/views/page-editor/sections/index.js.map +1 -0
  210. package/dist/views/page-editor/sections/parts.d.ts +55 -0
  211. package/dist/views/page-editor/sections/parts.d.ts.map +1 -0
  212. package/dist/views/page-editor/sections/parts.js +60 -0
  213. package/dist/views/page-editor/sections/parts.js.map +1 -0
  214. package/dist/views/page-editor/viewports.d.ts +14 -0
  215. package/dist/views/page-editor/viewports.d.ts.map +1 -0
  216. package/dist/views/page-editor/viewports.js +12 -0
  217. package/dist/views/page-editor/viewports.js.map +1 -0
  218. package/dist/views/post-editor/PostEditorCanvas.d.ts +21 -0
  219. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -0
  220. package/dist/views/post-editor/PostEditorCanvas.js +58 -0
  221. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -0
  222. package/dist/views/post-editor/PostFieldsModal.d.ts +12 -0
  223. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -0
  224. package/dist/views/post-editor/PostFieldsModal.js +53 -0
  225. package/dist/views/post-editor/PostFieldsModal.js.map +1 -0
  226. package/dist/views/post-editor/PostHeader.d.ts +15 -0
  227. package/dist/views/post-editor/PostHeader.d.ts.map +1 -0
  228. package/dist/views/post-editor/PostHeader.js +27 -0
  229. package/dist/views/post-editor/PostHeader.js.map +1 -0
  230. package/dist/views/post-editor/PostHeaderPanel.d.ts +11 -0
  231. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -0
  232. package/dist/views/post-editor/PostHeaderPanel.js +21 -0
  233. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -0
  234. package/dist/views/post-editor/PostPreview.d.ts +14 -0
  235. package/dist/views/post-editor/PostPreview.d.ts.map +1 -0
  236. package/dist/views/post-editor/PostPreview.js +53 -0
  237. package/dist/views/post-editor/PostPreview.js.map +1 -0
  238. package/dist/views/post-editor/PostSectionEditor.d.ts +17 -0
  239. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -0
  240. package/dist/views/post-editor/PostSectionEditor.js +290 -0
  241. package/dist/views/post-editor/PostSectionEditor.js.map +1 -0
  242. package/dist/views/post-editor/PostTemplateEditor.d.ts +9 -0
  243. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -0
  244. package/dist/views/post-editor/PostTemplateEditor.js +144 -0
  245. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -0
  246. package/package.json +2 -2
  247. package/src/AdminRoot.tsx +214 -2
  248. package/src/__tests__/components/posts-badges.test.tsx +77 -0
  249. package/src/__tests__/layout/sidebar-posts.test.ts +179 -0
  250. package/src/__tests__/lib/page-editor-service.test.ts +173 -0
  251. package/src/__tests__/lib/pages-service.test.ts +157 -0
  252. package/src/__tests__/lib/post-editor-service.test.ts +215 -0
  253. package/src/__tests__/lib/posts-service.test.ts +279 -0
  254. package/src/__tests__/lib/useApiData.test.ts +105 -0
  255. package/src/__tests__/views/dashboard.test.tsx +180 -0
  256. package/src/__tests__/views/posts-list-view.test.ts +110 -0
  257. package/src/components/pages/tag-badges.tsx +104 -0
  258. package/src/components/posts/badges.tsx +170 -0
  259. package/src/components/posts/icons.ts +40 -0
  260. package/src/layout/Layout.tsx +66 -10
  261. package/src/layout/Sidebar.tsx +116 -50
  262. package/src/lib/page-editor-service.ts +233 -0
  263. package/src/lib/pages-service.ts +599 -0
  264. package/src/lib/post-editor-service.ts +342 -0
  265. package/src/lib/posts-service.ts +366 -0
  266. package/src/lib/preview-link.ts +63 -0
  267. package/src/router/index.ts +10 -1
  268. package/src/views/Dashboard.tsx +142 -89
  269. package/src/views/Pages/PageTagManager.tsx +436 -0
  270. package/src/views/Pages/PagesListView.tsx +944 -0
  271. package/src/views/Posts/NewPostPicker.tsx +147 -0
  272. package/src/views/Posts/NewPostTypeInstructions.tsx +344 -0
  273. package/src/views/Posts/PostTypesView.tsx +194 -0
  274. package/src/views/Posts/PostsListView.tsx +869 -0
  275. package/src/views/Posts/post-type-presets.ts +105 -0
  276. package/src/views/page-builder/Widgets.tsx +44 -0
  277. package/src/views/page-editor/AddSectionModal.tsx +56 -0
  278. package/src/views/page-editor/EditorCanvas.tsx +127 -0
  279. package/src/views/page-editor/EditorTopBar.tsx +233 -0
  280. package/src/views/page-editor/PagePreview.tsx +96 -0
  281. package/src/views/page-editor/PageSectionEditor.tsx +476 -0
  282. package/src/views/page-editor/PageSettingsModal.tsx +154 -0
  283. package/src/views/page-editor/SectionInspector.tsx +599 -0
  284. package/src/views/page-editor/SectionsPanel.tsx +270 -0
  285. package/src/views/page-editor/ValidationSummary.tsx +53 -0
  286. package/src/views/page-editor/section-icons.ts +33 -0
  287. package/src/views/page-editor/section-types.ts +27 -0
  288. package/src/views/page-editor/sections/ArticleBodySection.tsx +57 -0
  289. package/src/views/page-editor/sections/AuthorBioSection.tsx +61 -0
  290. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +41 -0
  291. package/src/views/page-editor/sections/CtaBannerSection.tsx +29 -0
  292. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +63 -0
  293. package/src/views/page-editor/sections/GallerySection.tsx +54 -0
  294. package/src/views/page-editor/sections/HeroBannerSection.tsx +44 -0
  295. package/src/views/page-editor/sections/MissionSection.tsx +22 -0
  296. package/src/views/page-editor/sections/PullQuoteSection.tsx +27 -0
  297. package/src/views/page-editor/sections/RelatedPostsSection.tsx +55 -0
  298. package/src/views/page-editor/sections/SectionRenderer.tsx +102 -0
  299. package/src/views/page-editor/sections/index.ts +13 -0
  300. package/src/views/page-editor/sections/parts.tsx +113 -0
  301. package/src/views/page-editor/viewports.ts +25 -0
  302. package/src/views/post-editor/PostEditorCanvas.tsx +123 -0
  303. package/src/views/post-editor/PostFieldsModal.tsx +196 -0
  304. package/src/views/post-editor/PostHeader.tsx +87 -0
  305. package/src/views/post-editor/PostHeaderPanel.tsx +107 -0
  306. package/src/views/post-editor/PostPreview.tsx +114 -0
  307. package/src/views/post-editor/PostSectionEditor.tsx +506 -0
  308. package/src/views/post-editor/PostTemplateEditor.tsx +347 -0
@@ -0,0 +1,279 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+
3
+ // `cmsApi` is the only dependency the service uses; we mock it so the
4
+ // tests run hermetically without an HTTP server. Each mocked response
5
+ // follows the SDK's `{ data, error }` envelope.
6
+ const cmsApiMock = vi.fn()
7
+ vi.mock('../../lib/api.js', () => ({
8
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
9
+ }))
10
+
11
+ const { fetchPosts, fetchPostTypes } = await import('../../lib/posts-service.js')
12
+
13
+ interface DocFixture {
14
+ id: string
15
+ collection: string
16
+ title: string
17
+ slug: string
18
+ status: 'PUBLISHED' | 'DRAFT' | 'SCHEDULED'
19
+ createdById: string
20
+ updatedById: string
21
+ publishedAt: string | null
22
+ scheduledAt: string | null
23
+ createdAt: string
24
+ updatedAt: string
25
+ data: Record<string, unknown> | null
26
+ }
27
+
28
+ function doc(
29
+ o: Partial<DocFixture> & Pick<DocFixture, 'id' | 'collection' | 'title' | 'slug'>,
30
+ ): DocFixture {
31
+ return {
32
+ status: 'PUBLISHED',
33
+ createdById: 'u-david',
34
+ updatedById: 'u-david',
35
+ publishedAt: '2026-05-10T00:00:00.000Z',
36
+ scheduledAt: null,
37
+ createdAt: '2026-05-10T00:00:00.000Z',
38
+ updatedAt: '2026-05-10T00:00:00.000Z',
39
+ data: { seoScore: 80 },
40
+ ...o,
41
+ }
42
+ }
43
+
44
+ const COLLECTIONS_RESPONSE = [
45
+ {
46
+ slug: 'blog',
47
+ labels: { singular: 'Blog Post', plural: 'Blog' },
48
+ type: 'post',
49
+ urlPrefix: 'blog',
50
+ description: 'Blog posts',
51
+ icon: 'file-text',
52
+ color: 'purple',
53
+ group: 'Posts',
54
+ hidden: false,
55
+ fieldCount: 12,
56
+ },
57
+ {
58
+ slug: 'case-studies',
59
+ labels: { singular: 'Case Study', plural: 'Case Studies' },
60
+ type: 'post',
61
+ urlPrefix: 'case-studies',
62
+ description: 'Case studies',
63
+ icon: 'briefcase',
64
+ color: 'green',
65
+ group: 'Posts',
66
+ hidden: false,
67
+ fieldCount: 13,
68
+ },
69
+ {
70
+ slug: 'news',
71
+ labels: { singular: 'News Item', plural: 'News' },
72
+ type: 'post',
73
+ urlPrefix: 'news',
74
+ description: 'News',
75
+ icon: 'newspaper',
76
+ color: 'orange',
77
+ group: 'Posts',
78
+ hidden: false,
79
+ fieldCount: 11,
80
+ },
81
+ {
82
+ slug: 'pages',
83
+ labels: { singular: 'Page', plural: 'Pages' },
84
+ type: 'page',
85
+ urlPrefix: '',
86
+ description: null,
87
+ icon: null,
88
+ color: null,
89
+ group: null,
90
+ hidden: false,
91
+ fieldCount: 5,
92
+ },
93
+ ]
94
+
95
+ const USERS_RESPONSE = {
96
+ users: [
97
+ { id: 'u-sarah', name: 'Sarah Morgan', email: 'sarah@example.com' },
98
+ { id: 'u-maria', name: 'Maria K', email: 'maria@example.com' },
99
+ { id: 'u-david', name: 'David Carlson', email: 'david@example.com' },
100
+ ],
101
+ }
102
+
103
+ const DOCS: Record<string, DocFixture[]> = {
104
+ blog: [
105
+ doc({
106
+ id: 'b1',
107
+ collection: 'blog',
108
+ title: 'The Future of AI Content Marketing',
109
+ slug: 'ai-content-marketing',
110
+ createdById: 'u-maria',
111
+ updatedById: 'u-maria',
112
+ updatedAt: '2026-05-18T00:00:00.000Z',
113
+ data: { seoScore: 88 },
114
+ }),
115
+ doc({
116
+ id: 'b2',
117
+ collection: 'blog',
118
+ title: 'ActuateCMS vs Contentful',
119
+ slug: 'actuatecms-vs-contentful',
120
+ updatedAt: '2026-05-15T00:00:00.000Z',
121
+ data: { seoScore: 92 },
122
+ }),
123
+ doc({
124
+ id: 'b3',
125
+ collection: 'blog',
126
+ title: 'Mid-Year Review',
127
+ slug: 'mid-year-cms-trends',
128
+ status: 'DRAFT',
129
+ createdById: 'u-sarah',
130
+ updatedById: 'u-sarah',
131
+ publishedAt: null,
132
+ updatedAt: '2026-05-22T00:00:00.000Z',
133
+ data: { seoScore: null },
134
+ }),
135
+ ],
136
+ 'case-studies': [
137
+ doc({
138
+ id: 'c1',
139
+ collection: 'case-studies',
140
+ title: 'Healthcare Client SEO Results',
141
+ slug: 'healthcare-seo',
142
+ status: 'DRAFT',
143
+ publishedAt: null,
144
+ updatedAt: '2026-05-19T00:00:00.000Z',
145
+ data: { seoScore: 38 },
146
+ }),
147
+ ],
148
+ news: [
149
+ doc({
150
+ id: 'n1',
151
+ collection: 'news',
152
+ title: 'Q2 2026 Product Roadmap Update',
153
+ slug: 'q2-roadmap',
154
+ status: 'SCHEDULED',
155
+ createdById: 'u-sarah',
156
+ updatedById: 'u-sarah',
157
+ publishedAt: null,
158
+ scheduledAt: '2026-06-01T00:00:00.000Z',
159
+ updatedAt: '2026-05-24T00:00:00.000Z',
160
+ data: { seoScore: 62 },
161
+ }),
162
+ ],
163
+ }
164
+
165
+ function installDefaultMocks() {
166
+ cmsApiMock.mockImplementation((url: string) => {
167
+ if (url === '/collections') return { data: COLLECTIONS_RESPONSE }
168
+ if (url.startsWith('/users')) return { data: USERS_RESPONSE }
169
+ const m = url.match(/^\/collections\/([^/?]+)/)
170
+ if (m && m[1]) {
171
+ const slug = decodeURIComponent(m[1])
172
+ return { data: { docs: DOCS[slug] ?? [], total: DOCS[slug]?.length ?? 0 } }
173
+ }
174
+ return { error: `unexpected url: ${url}` }
175
+ })
176
+ }
177
+
178
+ describe('posts-service.fetchPostTypes', () => {
179
+ beforeEach(() => {
180
+ cmsApiMock.mockReset()
181
+ installDefaultMocks()
182
+ })
183
+
184
+ it('returns only post-typed collections (filters out type: page)', async () => {
185
+ const types = await fetchPostTypes()
186
+ expect(types.map((t) => t.slug).sort()).toEqual(['blog', 'case-studies', 'news'])
187
+ })
188
+
189
+ it('maps collection metadata onto the PostType shape', async () => {
190
+ const types = await fetchPostTypes()
191
+ const blog = types.find((t) => t.slug === 'blog')
192
+ expect(blog).toMatchObject({
193
+ id: 'blog',
194
+ slug: 'blog',
195
+ name: 'Blog',
196
+ singularName: 'Blog Post',
197
+ description: 'Blog posts',
198
+ urlBase: '/blog',
199
+ accentColor: 'purple',
200
+ icon: 'file-text',
201
+ fieldCount: 12,
202
+ active: true,
203
+ })
204
+ // postCount is 0 here because fetchPostTypes doesn't load docs.
205
+ expect(blog?.postCount).toBe(0)
206
+ })
207
+ })
208
+
209
+ describe('posts-service.fetchPosts', () => {
210
+ beforeEach(() => {
211
+ cmsApiMock.mockReset()
212
+ installDefaultMocks()
213
+ })
214
+
215
+ it('aggregates posts across all post-typed collections', async () => {
216
+ const res = await fetchPosts({ pageSize: 20 })
217
+ expect(res.total).toBe(5)
218
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b1', 'b2', 'b3', 'c1', 'n1'])
219
+ })
220
+
221
+ it('joins authors via /users', async () => {
222
+ const res = await fetchPosts({ pageSize: 20 })
223
+ const ai = res.posts.find((p) => p.id === 'b1')
224
+ expect(ai?.authorName).toBe('Maria K')
225
+ expect(ai?.authorInitials).toBe('MK')
226
+ })
227
+
228
+ it('sorts by updated date desc by default and surfaces scheduled date for SCHEDULED posts', async () => {
229
+ const res = await fetchPosts({ pageSize: 20 })
230
+ // The SCHEDULED news post should win the "date" sort because its
231
+ // scheduledAt is the most recent date in the fixture set.
232
+ expect(res.posts[0]?.id).toBe('n1')
233
+ expect(res.posts[0]?.publishDate).toBe('2026-06-01T00:00:00.000Z')
234
+ })
235
+
236
+ it('filters by postType', async () => {
237
+ const res = await fetchPosts({ postType: 'blog', pageSize: 20 })
238
+ expect(res.total).toBe(3)
239
+ expect(res.posts.every((p) => p.postTypeId === 'blog')).toBe(true)
240
+ })
241
+
242
+ it('filters by status', async () => {
243
+ const res = await fetchPosts({ status: 'DRAFT', pageSize: 20 })
244
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'c1'])
245
+ })
246
+
247
+ it('filters by author', async () => {
248
+ const res = await fetchPosts({ author: 'u-sarah', pageSize: 20 })
249
+ expect(res.posts.map((p) => p.id).sort()).toEqual(['b3', 'n1'])
250
+ })
251
+
252
+ it('searches across title, slug, author name, and post type', async () => {
253
+ const res = await fetchPosts({ search: 'roadmap' })
254
+ expect(res.posts).toHaveLength(1)
255
+ expect(res.posts[0]?.id).toBe('n1')
256
+ })
257
+
258
+ it('paginates and exposes the unfiltered tab counts', async () => {
259
+ const res = await fetchPosts({ pageSize: 2 })
260
+ expect(res.posts).toHaveLength(2)
261
+ expect(res.total).toBe(5)
262
+ const blogTab = res.types.find((t) => t.slug === 'blog')
263
+ expect(blogTab?.postCount).toBe(3)
264
+ const newsTab = res.types.find((t) => t.slug === 'news')
265
+ expect(newsTab?.postCount).toBe(1)
266
+ })
267
+
268
+ it('returns only authors actually present on posts', async () => {
269
+ const res = await fetchPosts({ pageSize: 20 })
270
+ expect(res.authors.map((a) => a.id).sort()).toEqual(['u-david', 'u-maria', 'u-sarah'])
271
+ })
272
+
273
+ it('uses the postType filter to scope the result set without touching the tab counts', async () => {
274
+ const res = await fetchPosts({ postType: 'news', pageSize: 20 })
275
+ expect(res.total).toBe(1)
276
+ // Tab counts still reflect the full corpus.
277
+ expect(res.types.find((t) => t.slug === 'blog')?.postCount).toBe(3)
278
+ })
279
+ })
@@ -0,0 +1,105 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { act, renderHook, waitFor } from '@testing-library/react'
4
+
5
+ // `cmsApi` is the hook's only I/O dependency — mock it so retry/backoff
6
+ // behaviour can be exercised deterministically. Responses follow the SDK
7
+ // `{ data, error, status }` envelope.
8
+ const cmsApiMock = vi.fn()
9
+ vi.mock('../../lib/api.js', () => ({
10
+ cmsApi: (...args: unknown[]) => cmsApiMock(...args),
11
+ }))
12
+
13
+ const { useApiData } = await import('../../lib/useApiData.js')
14
+
15
+ beforeEach(() => {
16
+ cmsApiMock.mockReset()
17
+ })
18
+
19
+ afterEach(() => {
20
+ vi.useRealTimers()
21
+ })
22
+
23
+ describe('useApiData', () => {
24
+ it('loads data on mount and clears the loading flag', async () => {
25
+ cmsApiMock.mockResolvedValue({ data: { ok: 1 }, status: 200 })
26
+
27
+ const { result } = renderHook(() => useApiData<{ ok: number }>('/x'))
28
+
29
+ expect(result.current.loading).toBe(true)
30
+ await waitFor(() => expect(result.current.loading).toBe(false))
31
+
32
+ expect(result.current.data).toEqual({ ok: 1 })
33
+ expect(result.current.error).toBeNull()
34
+ expect(result.current.exhausted).toBe(false)
35
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('does not fetch when the endpoint is null', () => {
39
+ const { result } = renderHook(() => useApiData<unknown>(null))
40
+
41
+ expect(result.current.loading).toBe(false)
42
+ expect(cmsApiMock).not.toHaveBeenCalled()
43
+ })
44
+
45
+ it('retries with exponential backoff, then marks the request exhausted', async () => {
46
+ vi.useFakeTimers()
47
+ cmsApiMock.mockResolvedValue({ error: 'boom', status: 500 })
48
+
49
+ const { result } = renderHook(() => useApiData('/x', null, { retryBaseMs: 10 }))
50
+
51
+ // Flush the initial attempt.
52
+ await act(async () => {
53
+ await vi.advanceTimersByTimeAsync(0)
54
+ })
55
+ expect(cmsApiMock).toHaveBeenCalledTimes(1)
56
+ expect(result.current.exhausted).toBe(false)
57
+
58
+ // Backoff schedule with base 10ms: 10, 20, 40.
59
+ await act(async () => {
60
+ await vi.advanceTimersByTimeAsync(10)
61
+ })
62
+ await act(async () => {
63
+ await vi.advanceTimersByTimeAsync(20)
64
+ })
65
+ await act(async () => {
66
+ await vi.advanceTimersByTimeAsync(40)
67
+ })
68
+
69
+ // 1 initial + 3 retries = 4 total, then it gives up.
70
+ expect(cmsApiMock).toHaveBeenCalledTimes(4)
71
+ expect(result.current.exhausted).toBe(true)
72
+ expect(result.current.error).toBe('boom')
73
+ expect(result.current.loading).toBe(false)
74
+ })
75
+
76
+ it('recovers via refetch after exhausting retries', async () => {
77
+ vi.useFakeTimers()
78
+ cmsApiMock.mockResolvedValue({ error: 'boom', status: 500 })
79
+
80
+ const { result } = renderHook(() =>
81
+ useApiData<{ ok: boolean }>('/x', null, { retryBaseMs: 10 }),
82
+ )
83
+
84
+ await act(async () => {
85
+ await vi.advanceTimersByTimeAsync(0)
86
+ })
87
+ // Run out the full backoff schedule (10 + 20 + 40).
88
+ await act(async () => {
89
+ await vi.advanceTimersByTimeAsync(70)
90
+ })
91
+ expect(result.current.exhausted).toBe(true)
92
+
93
+ cmsApiMock.mockResolvedValue({ data: { ok: true }, status: 200 })
94
+ await act(async () => {
95
+ result.current.refetch()
96
+ })
97
+ await act(async () => {
98
+ await vi.advanceTimersByTimeAsync(0)
99
+ })
100
+
101
+ expect(result.current.data).toEqual({ ok: true })
102
+ expect(result.current.exhausted).toBe(false)
103
+ expect(result.current.error).toBeNull()
104
+ })
105
+ })
@@ -0,0 +1,180 @@
1
+ // @vitest-environment happy-dom
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+
5
+ // Drive the dashboard purely through `useApiData`. Each endpoint's result
6
+ // is looked up in `responses`, so tests can set just the data they care
7
+ // about and everything else falls back to a benign empty state.
8
+ const responses: Record<string, unknown> = {}
9
+ const emptyResult = { data: null, loading: false, error: null, exhausted: false, refetch: () => {} }
10
+
11
+ vi.mock('../../lib/useApiData.js', () => ({
12
+ useApiData: (endpoint: string | null) => responses[endpoint ?? ''] ?? emptyResult,
13
+ }))
14
+
15
+ const { Dashboard } = await import('../../views/Dashboard.js')
16
+
17
+ const emptyStats = {
18
+ totalDocuments: 0,
19
+ totalMedia: 0,
20
+ totalUsers: 0,
21
+ formCount: 0,
22
+ avgSeoScore: 0,
23
+ webhookCount: 0,
24
+ webhookActiveCount: 0,
25
+ collectionCounts: {},
26
+ statusCounts: {},
27
+ recentDocuments: [] as unknown[],
28
+ }
29
+
30
+ function statsResult(over: Partial<typeof emptyStats> = {}) {
31
+ return {
32
+ data: { ...emptyStats, ...over },
33
+ loading: false,
34
+ error: null,
35
+ exhausted: false,
36
+ refetch: () => {},
37
+ }
38
+ }
39
+
40
+ beforeEach(() => {
41
+ for (const k of Object.keys(responses)) delete responses[k]
42
+ })
43
+
44
+ describe('Dashboard', () => {
45
+ it('renders a spinner while the primary stats request is still loading', () => {
46
+ responses['/stats'] = {
47
+ data: null,
48
+ loading: true,
49
+ error: null,
50
+ exhausted: false,
51
+ refetch: () => {},
52
+ }
53
+
54
+ const { container } = render(
55
+ <Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />,
56
+ )
57
+
58
+ expect(container.querySelector('.animate-spin')).toBeTruthy()
59
+ expect(screen.queryByText('Recent Activity')).toBeNull()
60
+ })
61
+
62
+ it('shows empty states when there is no content', () => {
63
+ responses['/stats'] = statsResult()
64
+
65
+ render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
66
+
67
+ expect(screen.getByText('No activity yet')).toBeTruthy()
68
+ expect(screen.getByText('No scheduled content')).toBeTruthy()
69
+ })
70
+
71
+ it('surfaces a retryable banner when the stats request is exhausted', () => {
72
+ const refetch = vi.fn()
73
+ responses['/stats'] = { data: null, loading: false, error: 'down', exhausted: true, refetch }
74
+
75
+ render(<Dashboard config={{ collections: [] }} session={{}} onNavigate={() => {}} />)
76
+
77
+ fireEvent.click(screen.getByText('Retry'))
78
+ expect(refetch).toHaveBeenCalledTimes(1)
79
+ })
80
+
81
+ // ── EI-1 regression: recent-activity rows must open the *correct* editor ──
82
+
83
+ it('routes a recent post to the section-based post editor (not the legacy editor)', () => {
84
+ const onNavigate = vi.fn()
85
+ responses['/stats'] = statsResult({
86
+ recentDocuments: [
87
+ {
88
+ id: 'p1',
89
+ title: 'My Post',
90
+ status: 'DRAFT',
91
+ collection: 'blog',
92
+ updatedAt: new Date().toISOString(),
93
+ author: 'Sam',
94
+ },
95
+ ],
96
+ })
97
+
98
+ render(
99
+ <Dashboard
100
+ config={{
101
+ collections: [
102
+ { slug: 'blog', type: 'post', labels: { singular: 'Blog', plural: 'Blogs' } },
103
+ ],
104
+ }}
105
+ session={{}}
106
+ onNavigate={onNavigate}
107
+ />,
108
+ )
109
+
110
+ // EI-2: the row is an accessible button, so it is reachable by role.
111
+ fireEvent.click(screen.getByRole('button', { name: /My Post/ }))
112
+ expect(onNavigate).toHaveBeenCalledWith('/posts/blog/p1/edit')
113
+ })
114
+
115
+ it('routes a recent page to the full page editor', () => {
116
+ const onNavigate = vi.fn()
117
+ responses['/stats'] = statsResult({
118
+ recentDocuments: [
119
+ {
120
+ id: 'pg1',
121
+ title: 'Home',
122
+ status: 'PUBLISHED',
123
+ collection: 'pages',
124
+ updatedAt: new Date().toISOString(),
125
+ author: 'Sam',
126
+ },
127
+ ],
128
+ })
129
+
130
+ render(
131
+ <Dashboard
132
+ config={{
133
+ collections: [
134
+ { slug: 'pages', type: 'page', labels: { singular: 'Page', plural: 'Pages' } },
135
+ ],
136
+ }}
137
+ session={{}}
138
+ onNavigate={onNavigate}
139
+ />,
140
+ )
141
+
142
+ fireEvent.click(screen.getByRole('button', { name: /Home/ }))
143
+ expect(onNavigate).toHaveBeenCalledWith('/pages/pg1/edit')
144
+ })
145
+
146
+ it('routes a scheduled queue item through the same editor resolver', () => {
147
+ const onNavigate = vi.fn()
148
+ responses['/stats'] = statsResult({
149
+ recentDocuments: [
150
+ {
151
+ id: 'p9',
152
+ title: 'Scheduled Post',
153
+ status: 'SCHEDULED',
154
+ collection: 'blog',
155
+ updatedAt: new Date().toISOString(),
156
+ author: 'Sam',
157
+ },
158
+ ],
159
+ })
160
+
161
+ render(
162
+ <Dashboard
163
+ config={{
164
+ collections: [
165
+ { slug: 'blog', type: 'post', labels: { singular: 'Blog', plural: 'Blogs' } },
166
+ ],
167
+ }}
168
+ session={{}}
169
+ onNavigate={onNavigate}
170
+ />,
171
+ )
172
+
173
+ // The publishing-queue row (right column) — there are two buttons named
174
+ // for the post (activity + queue); the queue "Manage" header button also
175
+ // targets the first scheduled item. Click the queue row directly.
176
+ const queueButtons = screen.getAllByRole('button', { name: /Scheduled Post/ })
177
+ fireEvent.click(queueButtons[queueButtons.length - 1]!)
178
+ expect(onNavigate).toHaveBeenCalledWith('/posts/blog/p9/edit')
179
+ })
180
+ })
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pageNumbers } from '../../views/Posts/PostsListView.js'
3
+ import { renderSnippet } from '../../views/Posts/NewPostTypeInstructions.js'
4
+ import { POST_TYPE_PRESETS } from '../../views/Posts/post-type-presets.js'
5
+ import { POST_TYPE_ICONS } from '../../components/posts/icons.js'
6
+
7
+ /**
8
+ * `pageNumbers` is the only pure helper exported from PostsListView.
9
+ * It drives the pagination footer's sliding window so the bug-prone
10
+ * cases (single page, exact-window boundary, ellipsis on either end)
11
+ * have explicit coverage.
12
+ */
13
+ describe('pageNumbers', () => {
14
+ it('returns 1..N when total ≤ 7', () => {
15
+ expect(pageNumbers(1, 1)).toEqual([1])
16
+ expect(pageNumbers(3, 5)).toEqual([1, 2, 3, 4, 5])
17
+ expect(pageNumbers(4, 7)).toEqual([1, 2, 3, 4, 5, 6, 7])
18
+ })
19
+
20
+ it('shows leading and trailing ellipses for a mid-range page', () => {
21
+ expect(pageNumbers(50, 100)).toEqual([1, '…', 49, 50, 51, '…', 100])
22
+ })
23
+
24
+ it('hides leading ellipsis when current is near the start', () => {
25
+ expect(pageNumbers(2, 20)).toEqual([1, 2, 3, '…', 20])
26
+ })
27
+
28
+ it('hides trailing ellipsis when current is near the end', () => {
29
+ expect(pageNumbers(19, 20)).toEqual([1, '…', 18, 19, 20])
30
+ })
31
+ })
32
+
33
+ describe('renderSnippet', () => {
34
+ const base = {
35
+ slug: 'reviews',
36
+ singular: 'Review',
37
+ plural: 'Reviews',
38
+ color: 'purple' as const,
39
+ icon: 'file-text' as const,
40
+ description: 'Reviews',
41
+ }
42
+
43
+ it('emits a valid-looking collection entry with the slug, labels, and admin metadata', () => {
44
+ const out = renderSnippet({ ...base, extraFields: [] })
45
+ expect(out).toContain("'reviews': {")
46
+ expect(out).toContain("slug: 'reviews'")
47
+ expect(out).toContain("singular: 'Review'")
48
+ expect(out).toContain("type: 'post'")
49
+ expect(out).toContain("group: 'Posts'")
50
+ expect(out).toContain("color: 'purple'")
51
+ // No type-specific block when there are no extra fields.
52
+ expect(out).not.toContain('type-specific fields')
53
+ })
54
+
55
+ it('injects preset extra fields after the shared base fields', () => {
56
+ const out = renderSnippet({
57
+ ...base,
58
+ extraFields: ["client: { type: 'text', label: 'Client Name' },"],
59
+ })
60
+ expect(out).toContain('// type-specific fields')
61
+ expect(out).toContain("client: { type: 'text', label: 'Client Name' },")
62
+ // Base fields are still present alongside the injected ones.
63
+ expect(out).toContain("title: { type: 'text', label: 'Title', required: true },")
64
+ expect(out).toContain("metaDescription: { type: 'text', label: 'SEO Description' },")
65
+ })
66
+
67
+ it('escapes single quotes in free-text fields so the snippet stays valid', () => {
68
+ const out = renderSnippet({
69
+ ...base,
70
+ description: "Editors' picks",
71
+ extraFields: [],
72
+ })
73
+ expect(out).toContain("description: 'Editors\\' picks'")
74
+ })
75
+
76
+ it('falls back to a placeholder slug when slug is empty', () => {
77
+ const out = renderSnippet({ ...base, slug: '', extraFields: [] })
78
+ expect(out).toContain("'new-type': {")
79
+ })
80
+ })
81
+
82
+ describe('POST_TYPE_PRESETS', () => {
83
+ it('has unique keys and slugs', () => {
84
+ const keys = POST_TYPE_PRESETS.map((p) => p.key)
85
+ const slugs = POST_TYPE_PRESETS.map((p) => p.slug)
86
+ expect(new Set(keys).size).toBe(keys.length)
87
+ expect(new Set(slugs).size).toBe(slugs.length)
88
+ })
89
+
90
+ it('only references icons that exist in the shared icon registry', () => {
91
+ for (const preset of POST_TYPE_PRESETS) {
92
+ expect(POST_TYPE_ICONS[preset.icon]).toBeDefined()
93
+ }
94
+ })
95
+
96
+ it('uses only palette colors the badge component understands', () => {
97
+ const allowed = new Set(['purple', 'green', 'orange', 'blue', 'red', 'pink'])
98
+ for (const preset of POST_TYPE_PRESETS) {
99
+ expect(allowed.has(preset.color)).toBe(true)
100
+ }
101
+ })
102
+
103
+ it('every preset extra field line is a complete key: { … }, definition', () => {
104
+ for (const preset of POST_TYPE_PRESETS) {
105
+ for (const line of preset.extraFields) {
106
+ expect(line).toMatch(/^[a-zA-Z][\w]*: \{.*\},$/)
107
+ }
108
+ }
109
+ })
110
+ })