@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.
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts +2 -0
- package/dist/__tests__/lib/page-editor-service-api.test.d.ts.map +1 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js +155 -0
- package/dist/__tests__/lib/page-editor-service-api.test.js.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts +2 -0
- package/dist/__tests__/lib/pages-service-fetch.test.d.ts.map +1 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js +125 -0
- package/dist/__tests__/lib/pages-service-fetch.test.js.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts +2 -0
- package/dist/__tests__/views/page-section-editor.test.d.ts.map +1 -0
- package/dist/__tests__/views/page-section-editor.test.js +56 -0
- package/dist/__tests__/views/page-section-editor.test.js.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts +2 -0
- package/dist/__tests__/views/pages-list-view.test.d.ts.map +1 -0
- package/dist/__tests__/views/pages-list-view.test.js +102 -0
- package/dist/__tests__/views/pages-list-view.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/lib/page-editor-service.d.ts +1 -0
- package/dist/lib/page-editor-service.d.ts.map +1 -1
- package/dist/lib/page-editor-service.js +55 -0
- package/dist/lib/page-editor-service.js.map +1 -1
- package/dist/lib/pages-service.d.ts +26 -0
- package/dist/lib/pages-service.d.ts.map +1 -1
- package/dist/lib/pages-service.js +65 -38
- package/dist/lib/pages-service.js.map +1 -1
- package/dist/views/Pages/PagesListView.d.ts.map +1 -1
- package/dist/views/Pages/PagesListView.js +71 -46
- package/dist/views/Pages/PagesListView.js.map +1 -1
- package/dist/views/page-editor/PagePreview.d.ts.map +1 -1
- package/dist/views/page-editor/PagePreview.js +10 -1
- package/dist/views/page-editor/PagePreview.js.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
- package/dist/views/page-editor/PageSectionEditor.js +22 -2
- package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/lib/page-editor-service-api.test.ts +180 -0
- package/src/__tests__/lib/pages-service-fetch.test.ts +160 -0
- package/src/__tests__/views/page-section-editor.test.tsx +65 -0
- package/src/__tests__/views/pages-list-view.test.tsx +119 -0
- package/src/lib/page-editor-service.ts +61 -0
- package/src/lib/pages-service.ts +88 -35
- package/src/views/Pages/PagesListView.tsx +113 -82
- package/src/views/page-editor/PagePreview.tsx +10 -1
- 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,
|
package/src/lib/pages-service.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
return { ok, failed }
|
|
545
|
+
return !res.error
|
|
546
|
+
})
|
|
494
547
|
}
|
|
495
548
|
|
|
496
549
|
// ─── Tag CRUD ────────────────────────────────────────────────────────
|