@actuate-media/cms-admin 0.17.0 → 0.18.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 (44) hide show
  1. package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
  2. package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
  3. package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
  4. package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
  5. package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
  6. package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
  7. package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
  8. package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
  9. package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
  10. package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
  11. package/dist/__tests__/views/page-section-editor.test.js +56 -0
  12. package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
  13. package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
  14. package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
  15. package/dist/__tests__/views/pages-list-view.test.js +102 -0
  16. package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
  17. package/dist/actuate-admin.css +1 -1
  18. package/dist/lib/page-editor-service.d.ts +1 -0
  19. package/dist/lib/page-editor-service.d.ts.map +1 -1
  20. package/dist/lib/page-editor-service.js +55 -0
  21. package/dist/lib/page-editor-service.js.map +1 -1
  22. package/dist/lib/pages-service.d.ts +26 -0
  23. package/dist/lib/pages-service.d.ts.map +1 -1
  24. package/dist/lib/pages-service.js +65 -38
  25. package/dist/lib/pages-service.js.map +1 -1
  26. package/dist/views/Pages/PagesListView.d.ts.map +1 -1
  27. package/dist/views/Pages/PagesListView.js +71 -46
  28. package/dist/views/Pages/PagesListView.js.map +1 -1
  29. package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
  30. package/dist/views/page-editor/PagePreview.js +10 -1
  31. package/dist/views/page-editor/PagePreview.js.map +1 -1
  32. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  33. package/dist/views/page-editor/PageSectionEditor.js +22 -2
  34. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  35. package/package.json +1 -1
  36. package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
  37. package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
  38. package/src/__tests__/views/page-section-editor.test.tsx +65 -0
  39. package/src/__tests__/views/pages-list-view.test.tsx +119 -0
  40. package/src/lib/page-editor-service.ts +61 -0
  41. package/src/lib/pages-service.ts +88 -35
  42. package/src/views/Pages/PagesListView.tsx +113 -82
  43. package/src/views/page-editor/PagePreview.tsx +10 -1
  44. package/src/views/page-editor/PageSectionEditor.tsx +25 -1
@@ -0,0 +1,65 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+
5
+ const fetchPageForEditor = vi.fn()
6
+ vi.mock('../../lib/page-editor-service.js', async (importOriginal) => {
7
+ const actual = await importOriginal<typeof import('../../lib/page-editor-service.js')>()
8
+ return {
9
+ ...actual,
10
+ fetchPageForEditor: (...args: unknown[]) => fetchPageForEditor(...args),
11
+ }
12
+ })
13
+
14
+ const { PageSectionEditor } = await import('../../views/page-editor/PageSectionEditor.js')
15
+
16
+ const validConfig = {
17
+ collections: {
18
+ pages: {
19
+ slug: 'pages',
20
+ fields: {
21
+ title: { type: 'text' },
22
+ slug: { type: 'slug' },
23
+ path: { type: 'text' },
24
+ sections: { type: 'json' },
25
+ metaTitle: { type: 'text' },
26
+ metaDescription: { type: 'text' },
27
+ },
28
+ },
29
+ },
30
+ }
31
+
32
+ afterEach(() => {
33
+ fetchPageForEditor.mockReset()
34
+ })
35
+
36
+ describe('PageSectionEditor schema guard', () => {
37
+ it('refuses to operate when the pages collection is missing the sections field', async () => {
38
+ const badConfig = {
39
+ collections: { pages: { slug: 'pages', fields: { title: { type: 'text' } } } },
40
+ }
41
+
42
+ render(
43
+ <PageSectionEditor documentId="p1" config={badConfig} onNavigate={() => {}} session={{}} />,
44
+ )
45
+
46
+ expect(await screen.findByText(/missing required field/i)).toBeTruthy()
47
+ expect(screen.getByText(/sections/)).toBeTruthy()
48
+ // The guard must short-circuit before any network read.
49
+ expect(fetchPageForEditor).not.toHaveBeenCalled()
50
+ })
51
+
52
+ it('does not block when no config is provided (embedding/test contexts)', async () => {
53
+ render(<PageSectionEditor config={undefined} onNavigate={() => {}} session={{}} />)
54
+ // New page (no documentId) → empty editor shell renders.
55
+ expect(await screen.findByText('Sections')).toBeTruthy()
56
+ })
57
+ })
58
+
59
+ describe('PageSectionEditor new page', () => {
60
+ it('renders the editor shell without fetching for /pages/new', async () => {
61
+ render(<PageSectionEditor config={validConfig} onNavigate={() => {}} session={{}} />)
62
+ expect(await screen.findByText('Sections')).toBeTruthy()
63
+ expect(fetchPageForEditor).not.toHaveBeenCalled()
64
+ })
65
+ })
@@ -0,0 +1,119 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
4
+
5
+ // Keep the pure shaping helpers (applyPagesQuery/groupPages) real — the
6
+ // list view relies on them for in-memory filtering — and stub only the
7
+ // network read + bulk mutations.
8
+ const fetchPagesDataset = vi.fn()
9
+ vi.mock('../../lib/pages-service.js', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('../../lib/pages-service.js')>()
11
+ return {
12
+ ...actual,
13
+ fetchPagesDataset: () => fetchPagesDataset(),
14
+ }
15
+ })
16
+
17
+ const { PagesListView } = await import('../../views/Pages/PagesListView.js')
18
+ import type { Page, PageTag, PagesDataset } from '../../lib/pages-service.js'
19
+
20
+ afterEach(() => {
21
+ fetchPagesDataset.mockReset()
22
+ })
23
+
24
+ function page(id: string, over: Partial<Page> = {}): Page {
25
+ return {
26
+ id,
27
+ title: id,
28
+ path: `/${id}`,
29
+ slug: id,
30
+ parentPageId: null,
31
+ tags: [],
32
+ primaryTagId: null,
33
+ authorId: 'u1',
34
+ authorName: 'Jane Doe',
35
+ authorInitials: 'JD',
36
+ status: 'PUBLISHED',
37
+ publishDate: null,
38
+ scheduledDate: null,
39
+ lastModified: '2026-01-01T00:00:00Z',
40
+ seoScore: 80,
41
+ seoTitle: null,
42
+ seoDescription: null,
43
+ templateId: null,
44
+ isHomepage: false,
45
+ isSystemPage: false,
46
+ createdAt: '2026-01-01T00:00:00Z',
47
+ updatedAt: '2026-01-01T00:00:00Z',
48
+ ...over,
49
+ }
50
+ }
51
+
52
+ function tag(slug: string, pageCount: number): PageTag {
53
+ return {
54
+ id: slug,
55
+ docId: `doc-${slug}`,
56
+ name: slug.toUpperCase(),
57
+ slug,
58
+ description: null,
59
+ color: 'green',
60
+ pageCount,
61
+ sortOrder: 0,
62
+ active: true,
63
+ createdAt: null,
64
+ updatedAt: null,
65
+ }
66
+ }
67
+
68
+ function dataset(over: Partial<PagesDataset> = {}): PagesDataset {
69
+ return { pages: [], tags: [], authors: [], ...over }
70
+ }
71
+
72
+ describe('PagesListView', () => {
73
+ it('counts untagged pages in the grand total (I-1 regression)', async () => {
74
+ // Two pages tagged "a", one untagged. A tag-sum total would report 2;
75
+ // the grand total must be 3.
76
+ fetchPagesDataset.mockResolvedValue(
77
+ dataset({
78
+ pages: [
79
+ page('p1', { tags: ['a'], primaryTagId: 'a' }),
80
+ page('p2', { tags: ['a'], primaryTagId: 'a' }),
81
+ page('p3'),
82
+ ],
83
+ tags: [tag('a', 2)],
84
+ }),
85
+ )
86
+
87
+ render(<PagesListView onNavigate={() => {}} />)
88
+
89
+ expect(await screen.findByText(/3 pages · organized by tags/)).toBeTruthy()
90
+ expect(screen.getByText('3 of 3')).toBeTruthy()
91
+ })
92
+
93
+ it('renders the empty state when there are no pages', async () => {
94
+ fetchPagesDataset.mockResolvedValue(dataset())
95
+ render(<PagesListView onNavigate={() => {}} />)
96
+ expect(await screen.findByText('No pages yet')).toBeTruthy()
97
+ })
98
+
99
+ it('shows an error with a retry that refetches', async () => {
100
+ fetchPagesDataset.mockRejectedValueOnce(new Error('Boom'))
101
+ render(<PagesListView onNavigate={() => {}} />)
102
+
103
+ expect(await screen.findByText('Boom')).toBeTruthy()
104
+
105
+ fetchPagesDataset.mockResolvedValueOnce(dataset({ pages: [page('p1')] }))
106
+ fireEvent.click(screen.getByText('Try again'))
107
+
108
+ await waitFor(() => expect(screen.getByText('1 of 1')).toBeTruthy())
109
+ })
110
+
111
+ it('navigates to the section editor when a row is opened', async () => {
112
+ const onNavigate = vi.fn()
113
+ fetchPagesDataset.mockResolvedValue(dataset({ pages: [page('about', { title: 'About' })] }))
114
+ render(<PagesListView onNavigate={onNavigate} />)
115
+
116
+ fireEvent.click(await screen.findByText('About'))
117
+ expect(onNavigate).toHaveBeenCalledWith('/pages/about/edit')
118
+ })
119
+ })
@@ -123,6 +123,62 @@ export function validatePage(page: EditorPage, forPublish = false): ValidationRe
123
123
  return { errors, warnings }
124
124
  }
125
125
 
126
+ // ─── Schema guard ────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * The Page Editor persists `sections` / `path` / `metaTitle` /
130
+ * `metaDescription` as top-level keys on a `pages` document. The server's
131
+ * field-access layer only writes keys that are *declared as fields* on the
132
+ * collection — any undeclared key is silently dropped on save. A `pages`
133
+ * collection missing the `sections` field would therefore discard all page
134
+ * content with no error.
135
+ *
136
+ * `missingPageFields` returns the required field keys absent from the
137
+ * `pages` collection so the editor can refuse to operate (and explain why)
138
+ * rather than corrupt data. When `config` (or its collections) is not
139
+ * provided — some embedding/test contexts don't pass it — we can't check,
140
+ * so we return `[]` and trust the consumer.
141
+ */
142
+ const REQUIRED_PAGE_FIELDS = ['sections', 'path', 'metaTitle', 'metaDescription'] as const
143
+
144
+ export function missingPageFields(config: unknown): string[] {
145
+ const pages = findPagesCollection(config)
146
+ if (!pages) return []
147
+ const declared = declaredFieldNames(pages.fields)
148
+ if (declared === null) return []
149
+ return REQUIRED_PAGE_FIELDS.filter((k) => !declared.has(k))
150
+ }
151
+
152
+ function findPagesCollection(config: unknown): { fields?: unknown } | null {
153
+ const collections = (config as { collections?: unknown } | null | undefined)?.collections
154
+ if (!collections) return null
155
+ if (Array.isArray(collections)) {
156
+ return (
157
+ (collections as Array<{ slug?: string; fields?: unknown }>).find(
158
+ (c) => c?.slug === PAGES_COLLECTION,
159
+ ) ?? null
160
+ )
161
+ }
162
+ if (typeof collections === 'object') {
163
+ return (collections as Record<string, { fields?: unknown }>)[PAGES_COLLECTION] ?? null
164
+ }
165
+ return null
166
+ }
167
+
168
+ /** Field definitions may be a record (keyed by name) or an array of `{ name }`. */
169
+ function declaredFieldNames(fields: unknown): Set<string> | null {
170
+ if (!fields) return null
171
+ if (Array.isArray(fields)) {
172
+ return new Set(
173
+ (fields as Array<{ name?: string }>)
174
+ .map((f) => f?.name)
175
+ .filter((n): n is string => typeof n === 'string'),
176
+ )
177
+ }
178
+ if (typeof fields === 'object') return new Set(Object.keys(fields as Record<string, unknown>))
179
+ return null
180
+ }
181
+
126
182
  // ─── API row mapping ─────────────────────────────────────────────────
127
183
 
128
184
  interface DocumentApiRow {
@@ -169,6 +225,11 @@ interface PageWritePayload {
169
225
  metaDescription: string
170
226
  }
171
227
 
228
+ // NOTE: `slug` may be empty for a brand-new draft. The `pages` collection
229
+ // declares `slug` as a `type: 'slug'` field with `from: 'title'`, so the
230
+ // server derives the slug from the title on create when none is supplied;
231
+ // the derived value comes back in the response and populates `EditorPage`.
232
+ // This is why `validatePage` requires a title but not a slug.
172
233
  function toWriteBody(page: EditorPage, extra?: Record<string, unknown>): string {
173
234
  const payload: PageWritePayload & Record<string, unknown> = {
174
235
  title: page.title,
@@ -94,13 +94,28 @@ export interface PagesQuery {
94
94
 
95
95
  export interface PagesResult {
96
96
  pages: Page[]
97
+ /** number of pages matching the active query (post-filter) */
97
98
  total: number
99
+ /** total pages in the collection, ignoring the active query/filters */
100
+ grandTotal: number
98
101
  page: number
99
102
  pageSize: number
100
103
  tags: PageTag[]
101
104
  authors: PageAuthor[]
102
105
  }
103
106
 
107
+ /**
108
+ * The full, unfiltered page set plus its tags and authors. The list view
109
+ * fetches this once and applies search / sort / group / paging in memory
110
+ * (see `applyPagesQuery` + `groupPages`), so typing in the search box or
111
+ * changing the sort never re-downloads the dataset.
112
+ */
113
+ export interface PagesDataset {
114
+ pages: Page[]
115
+ tags: PageTag[]
116
+ authors: PageAuthor[]
117
+ }
118
+
104
119
  // ─── API row shapes ──────────────────────────────────────────────────
105
120
 
106
121
  interface DocumentApiRow {
@@ -371,7 +386,13 @@ function statusLabel(s: PageStatus): string {
371
386
  return s.charAt(0) + s.slice(1).toLowerCase()
372
387
  }
373
388
 
374
- export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
389
+ /**
390
+ * Fetch the entire page dataset (unfiltered) plus tags and authors. This
391
+ * is the single network read backing the list view; callers apply query
392
+ * shaping in memory. Tag page counts and the author list are derived from
393
+ * the full set so they reflect the dataset, not any active filter.
394
+ */
395
+ export async function fetchPagesDataset(): Promise<PagesDataset> {
375
396
  const authorsMap = await fetchAuthorsMap()
376
397
 
377
398
  const [pagesRes, tagDocs] = await Promise.all([
@@ -383,8 +404,6 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
383
404
  docToPage(d, authorsMap),
384
405
  )
385
406
 
386
- // Tag page counts come from the full (unfiltered) set so chips and the
387
- // tag manager reflect the dataset, not the active query.
388
407
  const counts = new Map<string, number>()
389
408
  for (const p of allPages) {
390
409
  for (const slug of p.tags) counts.set(slug, (counts.get(slug) ?? 0) + 1)
@@ -393,16 +412,6 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
393
412
  .map((d) => docToTag(d, counts.get(d.slug ?? d.id) ?? 0))
394
413
  .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
395
414
 
396
- const filtered = applyPagesQuery(allPages, query)
397
-
398
- // Pagination only applies to the flat (ungrouped) view; grouped views
399
- // render every match so the buckets stay complete.
400
- const grouped = query.groupBy && query.groupBy !== 'none'
401
- const page = Math.max(1, query.page ?? 1)
402
- const pageSize = Math.max(1, query.pageSize ?? 25)
403
- const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
404
-
405
- // Authors present in the dataset (for the Author filter dropdown).
406
415
  const seen = new Set<string>()
407
416
  const authors: PageAuthor[] = []
408
417
  for (const p of allPages) {
@@ -413,7 +422,35 @@ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
413
422
  }
414
423
  authors.sort((a, b) => a.name.localeCompare(b.name))
415
424
 
416
- return { pages: paged, total: filtered.length, page, pageSize, tags, authors }
425
+ return { pages: allPages, tags, authors }
426
+ }
427
+
428
+ /**
429
+ * Query a paginated, filtered page list. Retained for non-list callers
430
+ * (tag detach/merge) and tests; the list view uses `fetchPagesDataset`
431
+ * directly and shapes the result in memory.
432
+ */
433
+ export async function fetchPages(query: PagesQuery = {}): Promise<PagesResult> {
434
+ const { pages: allPages, tags, authors } = await fetchPagesDataset()
435
+
436
+ const filtered = applyPagesQuery(allPages, query)
437
+
438
+ // Pagination only applies to the flat (ungrouped) view; grouped views
439
+ // render every match so the buckets stay complete.
440
+ const grouped = query.groupBy && query.groupBy !== 'none'
441
+ const page = Math.max(1, query.page ?? 1)
442
+ const pageSize = Math.max(1, query.pageSize ?? 25)
443
+ const paged = grouped ? filtered : filtered.slice((page - 1) * pageSize, page * pageSize)
444
+
445
+ return {
446
+ pages: paged,
447
+ total: filtered.length,
448
+ grandTotal: allPages.length,
449
+ page,
450
+ pageSize,
451
+ tags,
452
+ authors,
453
+ }
417
454
  }
418
455
 
419
456
  export async function fetchPageById(pageId: string): Promise<Page | null> {
@@ -432,34 +469,54 @@ export interface BulkResult {
432
469
  failed: number
433
470
  }
434
471
 
472
+ /**
473
+ * Run `task` over `items` with a bounded number of concurrent requests so
474
+ * a large selection doesn't fire one-at-a-time (slow) or all-at-once
475
+ * (overwhelms the API / rate limiter). Each task resolves to `true` on
476
+ * success; failures are counted, never thrown, so one bad row can't abort
477
+ * the batch.
478
+ */
479
+ async function mapWithConcurrency<T>(
480
+ items: T[],
481
+ limit: number,
482
+ task: (item: T) => Promise<boolean>,
483
+ ): Promise<BulkResult> {
484
+ let ok = 0
485
+ let failed = 0
486
+ let cursor = 0
487
+ const workerCount = Math.max(1, Math.min(limit, items.length))
488
+ const worker = async () => {
489
+ while (cursor < items.length) {
490
+ const item = items[cursor++]!
491
+ const success = await task(item).catch(() => false)
492
+ if (success) ok++
493
+ else failed++
494
+ }
495
+ }
496
+ await Promise.all(Array.from({ length: workerCount }, worker))
497
+ return { ok, failed }
498
+ }
499
+
435
500
  export async function bulkUpdatePages(
436
501
  ids: string[],
437
502
  payload: { status?: PageStatus; data?: Record<string, unknown> },
438
503
  ): Promise<BulkResult> {
439
- let ok = 0
440
- let failed = 0
441
- for (const id of ids) {
504
+ return mapWithConcurrency(ids, 5, async (id) => {
442
505
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
443
506
  method: 'PUT',
444
507
  body: JSON.stringify(payload),
445
508
  })
446
- if (res.error) failed++
447
- else ok++
448
- }
449
- return { ok, failed }
509
+ return !res.error
510
+ })
450
511
  }
451
512
 
452
513
  export async function bulkDeletePages(ids: string[]): Promise<BulkResult> {
453
- let ok = 0
454
- let failed = 0
455
- for (const id of ids) {
514
+ return mapWithConcurrency(ids, 5, async (id) => {
456
515
  const res = await cmsApi(`/collections/${PAGES_COLLECTION}/${encodeURIComponent(id)}`, {
457
516
  method: 'DELETE',
458
517
  })
459
- if (res.error) failed++
460
- else ok++
461
- }
462
- return { ok, failed }
518
+ return !res.error
519
+ })
463
520
  }
464
521
 
465
522
  /**
@@ -472,11 +529,9 @@ export async function bulkChangePageTags(
472
529
  pages: Pick<Page, 'id' | 'tags' | 'primaryTagId'>[],
473
530
  change: { add?: string[]; remove?: string[] },
474
531
  ): Promise<BulkResult> {
475
- let ok = 0
476
- let failed = 0
477
532
  const add = new Set(change.add ?? [])
478
533
  const remove = new Set(change.remove ?? [])
479
- for (const p of pages) {
534
+ return mapWithConcurrency(pages, 5, async (p) => {
480
535
  const next = new Set(p.tags)
481
536
  for (const t of add) next.add(t)
482
537
  for (const t of remove) next.delete(t)
@@ -487,10 +542,8 @@ export async function bulkChangePageTags(
487
542
  method: 'PUT',
488
543
  body: JSON.stringify({ data: { tags, primaryTag } }),
489
544
  })
490
- if (res.error) failed++
491
- else ok++
492
- }
493
- return { ok, failed }
545
+ return !res.error
546
+ })
494
547
  }
495
548
 
496
549
  // ─── Tag CRUD ────────────────────────────────────────────────────────