@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
@@ -13,16 +13,19 @@ import {
13
13
  X,
14
14
  } from 'lucide-react'
15
15
  import { toast } from 'sonner'
16
+ import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
16
17
  import {
18
+ applyPagesQuery,
17
19
  bulkChangePageTags,
18
20
  bulkDeletePages,
19
21
  bulkUpdatePages,
20
- fetchPages,
22
+ fetchPagesDataset,
21
23
  groupPages,
24
+ type BulkResult,
22
25
  type Page,
23
26
  type PageGroupBy,
27
+ type PagesDataset,
24
28
  type PageStatus,
25
- type PagesResult,
26
29
  type PageTag,
27
30
  } from '../../lib/pages-service.js'
28
31
  import { PostStatusBadge, SeoScoreIndicator } from '../../components/posts/badges.js'
@@ -61,7 +64,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
61
64
  const [page, setPage] = useState(1)
62
65
  const [selected, setSelected] = useState<Set<string>>(new Set())
63
66
 
64
- const [data, setData] = useState<PagesResult | null>(null)
67
+ const [dataset, setDataset] = useState<PagesDataset | null>(null)
65
68
  const [loading, setLoading] = useState(true)
66
69
  const [error, setError] = useState<string | null>(null)
67
70
  const [refreshToken, setRefreshToken] = useState(0)
@@ -81,23 +84,17 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
81
84
 
82
85
  const tagIds = useMemo(() => [...selectedTags], [selectedTags])
83
86
 
87
+ // Single network read. Search / sort / group / paging are applied in
88
+ // memory below, so changing any of those never re-fetches. The dataset
89
+ // refreshes only on mount and after a mutation (refreshToken bump).
84
90
  useEffect(() => {
85
91
  let cancelled = false
86
92
  setLoading(true)
87
93
  setError(null)
88
- fetchPages({
89
- status,
90
- tagIds,
91
- search: debouncedSearch,
92
- groupBy,
93
- sortBy,
94
- sortDirection: dir,
95
- page,
96
- pageSize: PAGE_SIZE,
97
- })
94
+ fetchPagesDataset()
98
95
  .then((res) => {
99
96
  if (cancelled) return
100
- setData(res)
97
+ setDataset(res)
101
98
  setLoading(false)
102
99
  })
103
100
  .catch((err: unknown) => {
@@ -108,21 +105,40 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
108
105
  return () => {
109
106
  cancelled = true
110
107
  }
111
- }, [status, tagIds, debouncedSearch, groupBy, sortBy, dir, page, refreshToken])
108
+ }, [refreshToken])
112
109
 
113
- // Reset paging/selection when the result set changes shape.
110
+ // Reset paging/selection when the active filters change shape.
114
111
  useEffect(() => {
115
112
  setPage(1)
116
113
  setSelected(new Set())
117
114
  }, [debouncedSearch, status, tagIds, groupBy])
118
115
 
119
- const pages = data?.pages ?? []
120
- const tags = data?.tags ?? []
121
- const total = data?.total ?? 0
122
- const totalAll = useMemo(() => tags.reduce((sum, t) => sum + t.pageCount, 0), [tags])
123
- const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
116
+ const allPages = useMemo(() => dataset?.pages ?? [], [dataset])
117
+ const tags = useMemo(() => dataset?.tags ?? [], [dataset])
118
+ const grandTotal = allPages.length
124
119
  const grouped = groupBy !== 'none'
125
120
 
121
+ const filtered = useMemo(
122
+ () =>
123
+ applyPagesQuery(allPages, {
124
+ status,
125
+ tagIds,
126
+ search: debouncedSearch,
127
+ sortBy,
128
+ sortDirection: dir,
129
+ }),
130
+ [allPages, status, tagIds, debouncedSearch, sortBy, dir],
131
+ )
132
+ const total = filtered.length
133
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
134
+
135
+ // Pagination only applies to the flat view; grouped views render every
136
+ // match so the buckets stay complete.
137
+ const pages = useMemo(
138
+ () => (grouped ? filtered : filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)),
139
+ [filtered, grouped, page],
140
+ )
141
+
126
142
  const tagBySlug = useMemo(() => {
127
143
  const m = new Map<string, PageTag>()
128
144
  for (const t of tags) m.set(t.slug, t)
@@ -143,6 +159,11 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
143
159
  [sortBy],
144
160
  )
145
161
 
162
+ // `aria-sort` belongs on the column header cell (`<th>`), not the button
163
+ // inside it — assistive tech reads sort state from the header.
164
+ const ariaSortFor = (field: SortBy): 'ascending' | 'descending' | 'none' =>
165
+ sortBy === field ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
166
+
146
167
  const filtersAreActive = debouncedSearch !== '' || status !== 'all' || selectedTags.size > 0
147
168
 
148
169
  const clearFilters = () => {
@@ -190,17 +211,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
190
211
  setRefreshToken((t) => t + 1)
191
212
  }
192
213
 
214
+ // Bulk service calls always resolve a { ok, failed } tally (they never
215
+ // reject), so we promote a *total* failure to a rejection — that's what
216
+ // makes `toast.promise`'s `error` branch reachable. The `await` is
217
+ // wrapped so a rejection can't escape as an unhandled promise, and
218
+ // `afterBulk` always runs to refresh the (possibly partially-changed) set.
219
+ const allFailed = (r: BulkResult) => r.ok === 0 && r.failed > 0
220
+
193
221
  const runBulkStatus = async (target: PageStatus) => {
194
222
  const ids = selectedPages.map((p) => p.id)
195
223
  if (ids.length === 0) return
196
224
  const verb = target === 'PUBLISHED' ? 'Publishing' : 'Updating'
197
- const promise = bulkUpdatePages(ids, { status: target })
225
+ const promise = bulkUpdatePages(ids, { status: target }).then((r) => {
226
+ if (allFailed(r)) throw new Error('Bulk update failed')
227
+ return r
228
+ })
198
229
  toast.promise(promise, {
199
230
  loading: `${verb} ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
200
231
  success: (r) => `${r.ok} updated${r.failed ? `, ${r.failed} failed` : ''}`,
201
232
  error: 'Bulk update failed',
202
233
  })
203
- await promise
234
+ try {
235
+ await promise
236
+ } catch {
237
+ /* surfaced via toast.promise */
238
+ }
204
239
  afterBulk()
205
240
  }
206
241
 
@@ -209,7 +244,10 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
209
244
  const promise = bulkChangePageTags(
210
245
  selectedPages.map((p) => ({ id: p.id, tags: p.tags, primaryTagId: p.primaryTagId })),
211
246
  mode === 'add' ? { add: [slug] } : { remove: [slug] },
212
- )
247
+ ).then((r) => {
248
+ if (allFailed(r)) throw new Error('Bulk tag change failed')
249
+ return r
250
+ })
213
251
  const tagName = tagBySlug.get(slug)?.name ?? slug
214
252
  toast.promise(promise, {
215
253
  loading: `${mode === 'add' ? 'Adding' : 'Removing'} "${tagName}"…`,
@@ -217,20 +255,31 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
217
255
  `${r.ok} page${r.ok === 1 ? '' : 's'} updated${r.failed ? `, ${r.failed} failed` : ''}`,
218
256
  error: 'Bulk tag change failed',
219
257
  })
220
- await promise
258
+ try {
259
+ await promise
260
+ } catch {
261
+ /* surfaced via toast.promise */
262
+ }
221
263
  afterBulk()
222
264
  }
223
265
 
224
266
  const runBulkDelete = async () => {
225
267
  const ids = deletablePages.map((p) => p.id)
226
268
  if (ids.length === 0) return
227
- const promise = bulkDeletePages(ids)
269
+ const promise = bulkDeletePages(ids).then((r) => {
270
+ if (allFailed(r)) throw new Error('Bulk delete failed')
271
+ return r
272
+ })
228
273
  toast.promise(promise, {
229
274
  loading: `Deleting ${ids.length} page${ids.length === 1 ? '' : 's'}…`,
230
275
  success: (r) => `${r.ok} deleted${r.failed ? `, ${r.failed} failed` : ''}`,
231
276
  error: 'Bulk delete failed',
232
277
  })
233
- await promise
278
+ try {
279
+ await promise
280
+ } catch {
281
+ /* surfaced via toast.promise */
282
+ }
234
283
  afterBulk()
235
284
  }
236
285
 
@@ -242,7 +291,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
242
291
  <div className="flex flex-col gap-1">
243
292
  <h1 className="text-foreground text-xl font-medium tracking-tight sm:text-2xl">Pages</h1>
244
293
  <p className="text-muted-foreground text-sm">
245
- {totalAll} page{totalAll === 1 ? '' : 's'} · organized by tags
294
+ {grandTotal} page{grandTotal === 1 ? '' : 's'} · organized by tags
246
295
  </p>
247
296
  </div>
248
297
 
@@ -291,7 +340,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
291
340
  />
292
341
 
293
342
  <span className="text-muted-foreground ml-auto text-sm tabular-nums">
294
- {total} of {totalAll}
343
+ {total} of {grandTotal}
295
344
  </span>
296
345
  </div>
297
346
 
@@ -369,7 +418,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
369
418
  className="border-border accent-primary rounded"
370
419
  />
371
420
  </th>
372
- <th scope="col" className="px-3 py-2.5 text-left">
421
+ <th scope="col" aria-sort={ariaSortFor('title')} className="px-3 py-2.5 text-left">
373
422
  <SortHeader
374
423
  label="Title"
375
424
  field="title"
@@ -381,7 +430,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
381
430
  <th scope="col" className="px-3 py-2.5 text-left">
382
431
  <span className="text-xs font-medium tracking-wider uppercase">Tags</span>
383
432
  </th>
384
- <th scope="col" className="px-3 py-2.5 text-left">
433
+ <th scope="col" aria-sort={ariaSortFor('author')} className="px-3 py-2.5 text-left">
385
434
  <SortHeader
386
435
  label="Author"
387
436
  field="author"
@@ -390,7 +439,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
390
439
  onSort={handleSort}
391
440
  />
392
441
  </th>
393
- <th scope="col" className="px-3 py-2.5 text-left">
442
+ <th scope="col" aria-sort={ariaSortFor('status')} className="px-3 py-2.5 text-left">
394
443
  <SortHeader
395
444
  label="Status"
396
445
  field="status"
@@ -399,7 +448,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
399
448
  onSort={handleSort}
400
449
  />
401
450
  </th>
402
- <th scope="col" className="px-3 py-2.5 text-left">
451
+ <th scope="col" aria-sort={ariaSortFor('modified')} className="px-3 py-2.5 text-left">
403
452
  <SortHeader
404
453
  label="Last Modified"
405
454
  field="modified"
@@ -408,7 +457,7 @@ export function PagesListView({ onNavigate }: PagesListViewProps) {
408
457
  onSort={handleSort}
409
458
  />
410
459
  </th>
411
- <th scope="col" className="px-3 py-2.5 text-left">
460
+ <th scope="col" aria-sort={ariaSortFor('seo')} className="px-3 py-2.5 text-left">
412
461
  <SortHeader
413
462
  label="SEO"
414
463
  field="seo"
@@ -513,12 +562,10 @@ function SortHeader({
513
562
  onSort: (f: SortBy) => void
514
563
  }) {
515
564
  const active = current === field
516
- const ariaSort = active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'
517
565
  return (
518
566
  <button
519
567
  type="button"
520
568
  onClick={() => onSort(field)}
521
- aria-sort={ariaSort}
522
569
  className={`hover:text-foreground inline-flex items-center gap-1 text-xs font-medium tracking-wider uppercase transition-colors ${
523
570
  active ? 'text-foreground' : 'text-muted-foreground'
524
571
  }`}
@@ -680,56 +727,40 @@ function TagMenu({
680
727
  tags: PageTag[]
681
728
  onPick: (slug: string) => void
682
729
  }) {
683
- const [open, setOpen] = useState(false)
684
- const ref = useRef<HTMLDivElement>(null)
685
-
686
- useEffect(() => {
687
- if (!open) return
688
- const onDoc = (e: MouseEvent) => {
689
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
690
- }
691
- document.addEventListener('mousedown', onDoc)
692
- return () => document.removeEventListener('mousedown', onDoc)
693
- }, [open])
694
-
695
730
  return (
696
- <div className="relative" ref={ref}>
697
- <button
698
- type="button"
699
- onClick={() => setOpen((o) => !o)}
700
- aria-haspopup="menu"
701
- aria-expanded={open}
702
- className="text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
703
- >
704
- {label}
705
- <ChevronDown className="h-3 w-3" aria-hidden />
706
- </button>
707
- {open && (
708
- <div
709
- role="menu"
710
- className="border-border bg-popover absolute z-20 mt-1 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-lg"
731
+ <DropdownMenu.Root>
732
+ <DropdownMenu.Trigger asChild>
733
+ <button
734
+ type="button"
735
+ className="text-foreground hover:bg-accent inline-flex items-center gap-1 rounded-md px-2 py-1 text-sm transition-colors"
711
736
  >
712
- {tags.length === 0 && (
737
+ {label}
738
+ <ChevronDown className="h-3 w-3" aria-hidden />
739
+ </button>
740
+ </DropdownMenu.Trigger>
741
+ <DropdownMenu.Portal>
742
+ <DropdownMenu.Content
743
+ align="start"
744
+ sideOffset={4}
745
+ className="border-border bg-popover z-50 max-h-64 w-44 overflow-auto rounded-lg border p-1 shadow-md"
746
+ >
747
+ {tags.length === 0 ? (
713
748
  <div className="text-muted-foreground px-2 py-1.5 text-xs">No tags yet</div>
749
+ ) : (
750
+ tags.map((t) => (
751
+ <DropdownMenu.Item
752
+ key={t.slug}
753
+ onSelect={() => onPick(t.slug)}
754
+ className="text-foreground focus:bg-accent flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none"
755
+ >
756
+ <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
757
+ {t.name}
758
+ </DropdownMenu.Item>
759
+ ))
714
760
  )}
715
- {tags.map((t) => (
716
- <button
717
- key={t.slug}
718
- type="button"
719
- role="menuitem"
720
- onClick={() => {
721
- onPick(t.slug)
722
- setOpen(false)
723
- }}
724
- className="text-foreground hover:bg-accent flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm"
725
- >
726
- <span aria-hidden className={`h-2 w-2 rounded-full ${dotFor(t.color ?? 'gray')}`} />
727
- {t.name}
728
- </button>
729
- ))}
730
- </div>
731
- )}
732
- </div>
761
+ </DropdownMenu.Content>
762
+ </DropdownMenu.Portal>
763
+ </DropdownMenu.Root>
733
764
  )
734
765
  }
735
766
 
@@ -62,7 +62,14 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
62
62
  <div className="bg-card border-border flex h-12 shrink-0 items-center justify-between border-b px-4">
63
63
  <button
64
64
  type="button"
65
- onClick={() => window.close()}
65
+ onClick={() => {
66
+ // Opened via the editor's "Preview" (script-opened tab) → close
67
+ // it. Opened by navigating to the URL directly (no opener, so
68
+ // `window.close()` is a no-op) → fall back to the editor so the
69
+ // button is never dead.
70
+ if (window.opener) window.close()
71
+ else onNavigate(`/pages/${documentId}/edit`)
72
+ }}
66
73
  className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1.5 text-sm"
67
74
  aria-label="Close preview tab"
68
75
  >
@@ -82,6 +89,8 @@ export function PagePreview({ documentId, onNavigate }: PagePreviewProps) {
82
89
  </button>
83
90
  </div>
84
91
 
92
+ {/* The canvas is intentionally white regardless of the admin theme:
93
+ it emulates the public page surface, not admin chrome. */}
85
94
  <div className="flex-1 overflow-auto bg-white">
86
95
  {visible.length === 0 ? (
87
96
  <div className="text-muted-foreground flex h-full items-center justify-center text-sm">
@@ -11,6 +11,7 @@ import {
11
11
  duplicateSection,
12
12
  emptyPage,
13
13
  fetchPageForEditor,
14
+ missingPageFields,
14
15
  moveSection,
15
16
  publishPage,
16
17
  removeSection,
@@ -88,12 +89,35 @@ export function PageSectionEditor({
88
89
 
89
90
  const { canEdit, canPublish } = useMemo(() => deriveCan(session), [session])
90
91
 
92
+ // A misconfigured `pages` collection (missing the fields the editor
93
+ // writes) would silently drop content on save — detect it up front.
94
+ // Memoized to a string/null so the load effect can depend on it without
95
+ // re-running when `config`'s object identity changes between renders.
96
+ const schemaError = useMemo(() => {
97
+ const missing = missingPageFields(config)
98
+ if (missing.length === 0) return null
99
+ const plural = missing.length > 1
100
+ return (
101
+ `The "pages" collection is missing required field${plural ? 's' : ''}: ` +
102
+ `${missing.join(', ')}. Declare ${plural ? 'them' : 'it'} in your CMS config ` +
103
+ `so the page editor can save without losing content.`
104
+ )
105
+ }, [config])
106
+
91
107
  // ─── Load ──────────────────────────────────────────────────────────
92
108
  useEffect(() => {
93
109
  let cancelled = false
94
110
  setLoading(true)
95
111
  setLoadError(null)
96
112
 
113
+ // Fail loud on a misconfigured `pages` collection rather than letting
114
+ // the user build a page whose content would be silently dropped on save.
115
+ if (schemaError) {
116
+ setLoadError(schemaError)
117
+ setLoading(false)
118
+ return
119
+ }
120
+
97
121
  if (!documentId) {
98
122
  setPage(emptyPage())
99
123
  setLoading(false)
@@ -119,7 +143,7 @@ export function PageSectionEditor({
119
143
  return () => {
120
144
  cancelled = true
121
145
  }
122
- }, [documentId])
146
+ }, [documentId, schemaError])
123
147
 
124
148
  // ─── Unsaved-changes guard (browser unload) ──────────────────────────
125
149
  useEffect(() => {