@byline/host-tanstack-start 4.16.0 → 4.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 (153) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.js +14 -14
  2. package/dist/admin-shell/chrome/dashboard.js +41 -5
  3. package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
  4. package/dist/admin-shell/chrome/router-pager.d.ts +7 -0
  5. package/dist/admin-shell/chrome/router-pager.js +13 -0
  6. package/dist/admin-shell/chrome/scroll-to-top.d.ts +20 -0
  7. package/dist/admin-shell/chrome/scroll-to-top.js +9 -0
  8. package/dist/admin-shell/collections/api.d.ts +2 -2
  9. package/dist/admin-shell/collections/create.d.ts +2 -2
  10. package/dist/admin-shell/collections/create.js +1 -0
  11. package/dist/admin-shell/collections/edit.d.ts +2 -2
  12. package/dist/admin-shell/collections/edit.js +1 -0
  13. package/dist/admin-shell/collections/history.d.ts +4 -12
  14. package/dist/admin-shell/collections/history.js +112 -303
  15. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  16. package/dist/admin-shell/collections/reindex-button.js +5 -1
  17. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  18. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  19. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  20. package/dist/admin-shell/collections/version-history.js +259 -0
  21. package/dist/admin-shell/singletons/history.d.ts +19 -0
  22. package/dist/admin-shell/singletons/history.js +170 -0
  23. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  24. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  25. package/dist/admin-shell/singletons/preview-link.js +54 -0
  26. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  27. package/dist/admin-shell/singletons/view-menu.js +116 -0
  28. package/dist/admin-shell/singletons/view.d.ts +18 -0
  29. package/dist/admin-shell/singletons/view.js +259 -0
  30. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  31. package/dist/integrations/api-utils.d.ts +18 -4
  32. package/dist/integrations/api-utils.js +10 -3
  33. package/dist/routes/create-admin-dashboard-route.js +2 -2
  34. package/dist/routes/create-collection-api-route.js +3 -3
  35. package/dist/routes/create-collection-create-route.js +4 -3
  36. package/dist/routes/create-collection-edit-route.js +4 -3
  37. package/dist/routes/create-collection-history-route.js +4 -3
  38. package/dist/routes/create-collection-list-route.js +4 -3
  39. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  40. package/dist/routes/create-singleton-history-route.js +99 -0
  41. package/dist/routes/create-singleton-route.d.ts +35 -0
  42. package/dist/routes/create-singleton-route.js +130 -0
  43. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  44. package/dist/routes/get-multi-collection-definition.js +6 -0
  45. package/dist/routes/get-singleton-definition.d.ts +10 -0
  46. package/dist/routes/get-singleton-definition.js +6 -0
  47. package/dist/routes/index.d.ts +2 -0
  48. package/dist/routes/index.js +2 -0
  49. package/dist/server-fns/actors.d.ts +16 -0
  50. package/dist/server-fns/actors.js +25 -0
  51. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  52. package/dist/server-fns/admin-document-presentation.js +32 -0
  53. package/dist/server-fns/collections/actors.d.ts +1 -10
  54. package/dist/server-fns/collections/actors.js +1 -25
  55. package/dist/server-fns/collections/get.js +9 -26
  56. package/dist/server-fns/collections/reorder.js +1 -1
  57. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  58. package/dist/server-fns/collections/upload.js +3 -3
  59. package/dist/server-fns/collections/utils.d.ts +1 -7
  60. package/dist/server-fns/collections/utils.js +1 -4
  61. package/dist/server-fns/serialise.d.ts +14 -0
  62. package/dist/server-fns/serialise.js +4 -0
  63. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  64. package/dist/server-fns/singleton-document-read.js +10 -0
  65. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  66. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  67. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  68. package/dist/server-fns/singletons/change-status.js +7 -0
  69. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  70. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  71. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  72. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  73. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  74. package/dist/server-fns/singletons/find-by-version.js +9 -0
  75. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  76. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  77. package/dist/server-fns/singletons/get.d.ts +24 -0
  78. package/dist/server-fns/singletons/get.js +6 -0
  79. package/dist/server-fns/singletons/history.d.ts +31 -0
  80. package/dist/server-fns/singletons/history.js +22 -0
  81. package/dist/server-fns/singletons/index.d.ts +19 -0
  82. package/dist/server-fns/singletons/index.js +12 -0
  83. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  84. package/dist/server-fns/singletons/restore-version.js +7 -0
  85. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  86. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  87. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  88. package/dist/server-fns/singletons/unpublish.js +7 -0
  89. package/dist/server-fns/singletons/update.d.ts +14 -0
  90. package/dist/server-fns/singletons/update.js +14 -0
  91. package/dist/vitest-jsdom.test.d.ts +8 -0
  92. package/package.json +24 -18
  93. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -13
  94. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  95. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  96. package/src/admin-shell/chrome/router-pager.tsx +22 -0
  97. package/src/admin-shell/chrome/scroll-to-top.test.ts +56 -0
  98. package/src/admin-shell/chrome/scroll-to-top.ts +28 -0
  99. package/src/admin-shell/collections/api.tsx +2 -2
  100. package/src/admin-shell/collections/create.tsx +6 -2
  101. package/src/admin-shell/collections/edit.tsx +6 -2
  102. package/src/admin-shell/collections/history.module.css +1 -1
  103. package/src/admin-shell/collections/history.tsx +162 -442
  104. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  105. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  106. package/src/admin-shell/collections/version-history.tsx +349 -0
  107. package/src/admin-shell/singletons/history.test.tsx +407 -0
  108. package/src/admin-shell/singletons/history.tsx +185 -0
  109. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  110. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  111. package/src/admin-shell/singletons/view.test.tsx +400 -0
  112. package/src/admin-shell/singletons/view.tsx +351 -0
  113. package/src/integrations/api-utils.ts +34 -9
  114. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  115. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  116. package/src/routes/create-collection-api-route.tsx +4 -4
  117. package/src/routes/create-collection-create-route.tsx +5 -8
  118. package/src/routes/create-collection-edit-route.tsx +5 -4
  119. package/src/routes/create-collection-history-route.tsx +5 -8
  120. package/src/routes/create-collection-list-route.tsx +5 -8
  121. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  122. package/src/routes/create-singleton-history-route.tsx +116 -0
  123. package/src/routes/create-singleton-route.test.node.ts +172 -0
  124. package/src/routes/create-singleton-route.tsx +176 -0
  125. package/src/routes/get-multi-collection-definition.ts +16 -0
  126. package/src/routes/get-singleton-definition.ts +16 -0
  127. package/src/routes/index.ts +2 -0
  128. package/src/server-fns/actors.ts +43 -0
  129. package/src/server-fns/admin-document-presentation.ts +78 -0
  130. package/src/server-fns/collections/actors.ts +1 -52
  131. package/src/server-fns/collections/get.ts +10 -49
  132. package/src/server-fns/collections/reorder.ts +1 -1
  133. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  134. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  135. package/src/server-fns/collections/upload.ts +7 -3
  136. package/src/server-fns/collections/utils.ts +1 -9
  137. package/src/server-fns/serialise.ts +17 -0
  138. package/src/server-fns/singleton-document-read.ts +32 -0
  139. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  140. package/src/server-fns/singletons/change-status.ts +19 -0
  141. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  142. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  143. package/src/server-fns/singletons/find-by-version.ts +23 -0
  144. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  145. package/src/server-fns/singletons/get.ts +32 -0
  146. package/src/server-fns/singletons/history.ts +41 -0
  147. package/src/server-fns/singletons/index.ts +20 -0
  148. package/src/server-fns/singletons/restore-version.ts +19 -0
  149. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  150. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  151. package/src/server-fns/singletons/unpublish.ts +19 -0
  152. package/src/server-fns/singletons/update.ts +31 -0
  153. package/src/vitest-jsdom.test.tsx +27 -0
@@ -0,0 +1,199 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type React from 'react'
10
+ import { act } from 'react'
11
+
12
+ import { defineAdminConfig, defineSingleton, type MultiCollectionDefinition } from '@byline/core'
13
+ import { createRoot } from 'react-dom/client'
14
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ const abilityState = vi.hoisted(() => ({
17
+ isSuperAdmin: false,
18
+ abilities: [] as string[],
19
+ }))
20
+
21
+ vi.mock('../../integrations/abilities.jsx', () => ({
22
+ useAbilities: () => ({
23
+ isSuperAdmin: abilityState.isSuperAdmin,
24
+ abilities: abilityState.abilities,
25
+ has: () => false,
26
+ hasAny: () => false,
27
+ }),
28
+ }))
29
+
30
+ vi.mock('@byline/i18n/react', () => ({
31
+ useTranslation: () => ({
32
+ t: (key: string, values?: Record<string, string | number>) => {
33
+ if (key === 'dashboard.totalCount') return `${values?.count} total`
34
+ if (key === 'dashboard.collectionDescription') return `Manage ${values?.label}`
35
+ return key
36
+ },
37
+ }),
38
+ }))
39
+
40
+ vi.mock('@byline/ui/react', () => {
41
+ type Props = { children?: React.ReactNode; className?: string }
42
+ const element = (tag: keyof React.JSX.IntrinsicElements) =>
43
+ function Element({ children, ...props }: Props) {
44
+ const Component = tag
45
+ return <Component {...props}>{children}</Component>
46
+ }
47
+ const Card = element('article') as ReturnType<typeof element> & {
48
+ Header: ReturnType<typeof element>
49
+ Title: ReturnType<typeof element>
50
+ Description: ReturnType<typeof element>
51
+ Content: ReturnType<typeof element>
52
+ }
53
+ Card.Header = element('header')
54
+ Card.Title = element('h3')
55
+ Card.Description = element('p')
56
+ Card.Content = element('div')
57
+ return { Card, Container: element('div'), Section: element('section') }
58
+ })
59
+
60
+ vi.mock('./loose-router.js', () => ({
61
+ Link: ({
62
+ to = '',
63
+ params = {},
64
+ search: _search,
65
+ children,
66
+ ...props
67
+ }: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
68
+ to?: string
69
+ params?: Record<string, string>
70
+ search?: unknown
71
+ }) => {
72
+ let href = to
73
+ for (const [name, value] of Object.entries(params)) {
74
+ href = href.replace(`$${name}`, value)
75
+ }
76
+ return (
77
+ <a href={href} {...props}>
78
+ {children}
79
+ </a>
80
+ )
81
+ },
82
+ }))
83
+
84
+ import { AdminDashboard } from './dashboard.js'
85
+
86
+ const articles: MultiCollectionDefinition = {
87
+ path: 'articles',
88
+ labels: { singular: 'Article', plural: 'Articles' },
89
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
90
+ showStats: true,
91
+ }
92
+
93
+ const settings = defineSingleton({
94
+ path: 'site-settings',
95
+ label: 'Site settings',
96
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
97
+ })
98
+
99
+ function renderDashboard(statsMap: Record<string, Array<{ status: string; count: number }>> = {}) {
100
+ const container = document.createElement('div')
101
+ document.body.append(container)
102
+ const root = createRoot(container)
103
+ act(() => root.render(<AdminDashboard statsMap={statsMap} />))
104
+ return {
105
+ container,
106
+ dispose() {
107
+ act(() => root.unmount())
108
+ container.remove()
109
+ },
110
+ }
111
+ }
112
+
113
+ describe('AdminDashboard document resources', () => {
114
+ beforeAll(() => {
115
+ ;(
116
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
117
+ ).IS_REACT_ACT_ENVIRONMENT = true
118
+ })
119
+
120
+ afterAll(() => {
121
+ delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
122
+ .IS_REACT_ACT_ENVIRONMENT
123
+ })
124
+
125
+ beforeEach(() => {
126
+ abilityState.isSuperAdmin = false
127
+ abilityState.abilities = []
128
+ defineAdminConfig({
129
+ routes: { admin: '/internal/cms' },
130
+ collections: [articles, settings],
131
+ admin: [
132
+ { slug: articles.path, group: 'content' },
133
+ { singleton: true, slug: settings.path, group: 'content' },
134
+ ],
135
+ collectionGroups: [{ name: 'content', label: 'Content' }],
136
+ i18n: {
137
+ admin: { defaultLocale: 'en', locales: [] },
138
+ content: { defaultLocale: 'en', locales: [] },
139
+ },
140
+ })
141
+ })
142
+
143
+ it('shows a singleton only for its kind-aware read ability', () => {
144
+ abilityState.abilities = ['singletons.site-settings.read']
145
+ const allowed = renderDashboard()
146
+ expect(allowed.container.textContent).toContain('Site settings')
147
+ expect(allowed.container.textContent).not.toContain('Articles')
148
+ allowed.dispose()
149
+
150
+ abilityState.abilities = ['collections.site-settings.read']
151
+ const staleNamespace = renderDashboard()
152
+ expect(staleNamespace.container.textContent).not.toContain('Site settings')
153
+ staleNamespace.dispose()
154
+ })
155
+
156
+ it('shows both resource kinds to a super admin in registry order', () => {
157
+ abilityState.isSuperAdmin = true
158
+ const view = renderDashboard({ articles: [{ status: 'draft', count: 3 }] })
159
+
160
+ const titles = [...view.container.querySelectorAll('.byline-dashboard-title-text')].map(
161
+ (element) => element.textContent
162
+ )
163
+ expect(titles).toEqual(['Articles', 'Site settings'])
164
+ expect(view.container.querySelector('.byline-dashboard-group-heading')?.textContent).toBe(
165
+ 'Content'
166
+ )
167
+ view.dispose()
168
+ })
169
+
170
+ it('links a singleton under the custom admin base without collection stats', () => {
171
+ abilityState.isSuperAdmin = true
172
+ const view = renderDashboard({
173
+ articles: [{ status: 'draft', count: 3 }],
174
+ 'site-settings': [{ status: 'published', count: 99 }],
175
+ })
176
+
177
+ const link = view.container.querySelector<HTMLAnchorElement>(
178
+ 'a[href="/internal/cms/singletons/site-settings"]'
179
+ )
180
+ const card = link?.closest('article')
181
+ expect(link).not.toBeNull()
182
+ expect(card?.querySelector('.byline-dashboard-stat-tile')).toBeNull()
183
+ expect(card?.querySelector('.byline-dashboard-title-meta')).toBeNull()
184
+ expect(card?.textContent).not.toContain('99')
185
+ expect(card?.textContent?.match(/Manage Site settings/g)).toHaveLength(1)
186
+ expect(
187
+ view.container.querySelector('a[href="/internal/cms/collections/articles"]')
188
+ ).not.toBeNull()
189
+ expect(view.container.querySelector('.byline-dashboard-stat-tile')).not.toBeNull()
190
+ view.dispose()
191
+ })
192
+
193
+ it('hides an update-only singleton role from the dashboard', () => {
194
+ abilityState.abilities = ['singletons.site-settings.update']
195
+ const view = renderDashboard()
196
+ expect(view.container.textContent).not.toContain('Site settings')
197
+ view.dispose()
198
+ })
199
+ })
@@ -6,12 +6,13 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type { CollectionDefinition, WorkflowStatus } from '@byline/core'
9
+ import type { MultiCollectionDefinition, SingletonDefinition, WorkflowStatus } from '@byline/core'
10
10
  import {
11
11
  filterReadableCollections,
12
12
  getAdminConfig,
13
13
  getWorkflowStatuses,
14
14
  groupCollectionsForAdmin,
15
+ isSingleton,
15
16
  } from '@byline/core'
16
17
  import { useTranslation } from '@byline/i18n/react'
17
18
  import { Card, Container, Section } from '@byline/ui/react'
@@ -68,7 +69,7 @@ function CollectionCard({
68
69
  collection,
69
70
  stats,
70
71
  }: {
71
- collection: CollectionDefinition
72
+ collection: MultiCollectionDefinition
72
73
  stats: CollectionStatusCount[] | undefined
73
74
  }) {
74
75
  const { t } = useTranslation('byline-admin')
@@ -134,6 +135,35 @@ function CollectionCard({
134
135
  )
135
136
  }
136
137
 
138
+ function SingletonCard({ singleton }: { singleton: SingletonDefinition }) {
139
+ const { t } = useTranslation('byline-admin')
140
+ const destination = getAdminRoutePath('singletons', '$singleton')
141
+ const params = { singleton: singleton.path }
142
+
143
+ return (
144
+ <Card>
145
+ <Link
146
+ to={destination}
147
+ params={params}
148
+ className={cx('byline-dashboard-card-link', styles.cardLink)}
149
+ >
150
+ <Card.Header>
151
+ <div className={cx('byline-dashboard-card-header', styles.cardHeader)}>
152
+ <Card.Title className={cx('byline-dashboard-card-title', styles.cardTitle)}>
153
+ <span className={cx('byline-dashboard-title-text', styles.titleText)}>
154
+ {singleton.label}
155
+ </span>
156
+ </Card.Title>
157
+ <Card.Description className="muted">
158
+ {t('dashboard.collectionDescription', { label: singleton.label })}
159
+ </Card.Description>
160
+ </div>
161
+ </Card.Header>
162
+ </Link>
163
+ </Card>
164
+ )
165
+ }
166
+
137
167
  interface AdminDashboardProps {
138
168
  statsMap: Record<string, CollectionStatusCount[]>
139
169
  }
@@ -173,13 +203,17 @@ export function AdminDashboard({ statsMap }: AdminDashboardProps) {
173
203
  </h2>
174
204
  )}
175
205
  <div className={cx('byline-dashboard-grid', styles.grid)}>
176
- {bucket.collections.map((collection) => (
177
- <CollectionCard
178
- key={collection.path}
179
- collection={collection}
180
- stats={statsMap[collection.path]}
181
- />
182
- ))}
206
+ {bucket.collections.map((resource) =>
207
+ isSingleton(resource) ? (
208
+ <SingletonCard key={resource.path} singleton={resource} />
209
+ ) : (
210
+ <CollectionCard
211
+ key={resource.path}
212
+ collection={resource}
213
+ stats={statsMap[resource.path]}
214
+ />
215
+ )
216
+ )}
183
217
  </div>
184
218
  </section>
185
219
  ))}
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useCallback } from 'react'
9
10
  import { useRouterState } from '@tanstack/react-router'
10
11
 
11
12
  import type { PaginationProps } from '@byline/ui/react'
@@ -18,6 +19,7 @@ import {
18
19
  } from '@byline/ui/react'
19
20
 
20
21
  import { Link } from './loose-router.js'
22
+ import { scrollWindowToTop } from './scroll-to-top.js'
21
23
 
22
24
  interface RouterPageProps extends PaginationProps {
23
25
  smoothScrollToTop?: boolean
@@ -27,6 +29,13 @@ interface RouterPageProps extends PaginationProps {
27
29
  * A convenience pager wrapped around uikit's Pagination with `asChild`
28
30
  * targets that delegate to TanStack Router's `<Link>`. `asChild` merges
29
31
  * the existing button styles into the rendered Link.
32
+ *
33
+ * `smoothScrollToTop` is for a pager sitting *below* a long list: turning a
34
+ * page there should carry the reader back to the top rather than leaving them
35
+ * at the foot of a fresh page. It suppresses the router's own instant
36
+ * `resetScroll` and animates instead — so the component owes an actual
37
+ * scroll, which was the missing half: the flag disabled the reset and put
38
+ * nothing in its place, so a bottom pager did not scroll at all.
30
39
  */
31
40
  export function RouterPager({
32
41
  className,
@@ -36,6 +45,14 @@ export function RouterPager({
36
45
  }: RouterPageProps): React.JSX.Element {
37
46
  const location = useRouterState({ select: (s) => s.location })
38
47
 
48
+ // Fires alongside navigation rather than after it: the scroll and the new
49
+ // page load run together, so the list is in place by the time the reader
50
+ // arrives at the top.
51
+ const handleScrollToTop = useCallback(() => {
52
+ if (smoothScrollToTop !== true) return
53
+ scrollWindowToTop()
54
+ }, [smoothScrollToTop])
55
+
39
56
  return (
40
57
  <Pagination variant="dashboard" {...rest}>
41
58
  <Pagination.Root className={className} ariaLabel={ariaLabel}>
@@ -51,6 +68,7 @@ export function RouterPager({
51
68
  ) : (
52
69
  <Link
53
70
  resetScroll={smoothScrollToTop !== true}
71
+ onClick={handleScrollToTop}
54
72
  to={location.pathname as never}
55
73
  search={params}
56
74
  />
@@ -76,6 +94,7 @@ export function RouterPager({
76
94
  ) : (
77
95
  <Link
78
96
  resetScroll={smoothScrollToTop !== true}
97
+ onClick={handleScrollToTop}
79
98
  to={location.pathname as never}
80
99
  search={params}
81
100
  />
@@ -103,6 +122,7 @@ export function RouterPager({
103
122
  ) : (
104
123
  <Link
105
124
  resetScroll={smoothScrollToTop !== true}
125
+ onClick={handleScrollToTop}
106
126
  to={location.pathname as never}
107
127
  search={params}
108
128
  />
@@ -130,6 +150,7 @@ export function RouterPager({
130
150
  ) : (
131
151
  <Link
132
152
  resetScroll={smoothScrollToTop !== true}
153
+ onClick={handleScrollToTop}
133
154
  to={location.pathname as never}
134
155
  search={params}
135
156
  />
@@ -156,6 +177,7 @@ export function RouterPager({
156
177
  ) : (
157
178
  <Link
158
179
  resetScroll={smoothScrollToTop !== true}
180
+ onClick={handleScrollToTop}
159
181
  to={location.pathname as never}
160
182
  search={params}
161
183
  />
@@ -0,0 +1,56 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ import { scrollWindowToTop } from './scroll-to-top.js'
12
+
13
+ function mockReducedMotion(matches: boolean) {
14
+ vi.stubGlobal(
15
+ 'matchMedia',
16
+ vi.fn((query: string) => ({ matches, media: query })) as unknown as typeof window.matchMedia
17
+ )
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals()
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ describe('scrollWindowToTop', () => {
26
+ it('scrolls to the top of the page', () => {
27
+ const scrollTo = vi.fn()
28
+ vi.stubGlobal('scrollTo', scrollTo)
29
+ mockReducedMotion(false)
30
+
31
+ scrollWindowToTop()
32
+
33
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
34
+ })
35
+
36
+ it('skips the animation when the reader asks for reduced motion', () => {
37
+ const scrollTo = vi.fn()
38
+ vi.stubGlobal('scrollTo', scrollTo)
39
+ mockReducedMotion(true)
40
+
41
+ scrollWindowToTop()
42
+
43
+ // Still goes to the top — only the travel is dropped.
44
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' })
45
+ })
46
+
47
+ /** Older browsers and some test environments have no `matchMedia`. */
48
+ it('still scrolls when matchMedia is unavailable', () => {
49
+ const scrollTo = vi.fn()
50
+ vi.stubGlobal('scrollTo', scrollTo)
51
+ vi.stubGlobal('matchMedia', undefined)
52
+
53
+ expect(() => scrollWindowToTop()).not.toThrow()
54
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
55
+ })
56
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Return the reader to the top of the page.
11
+ *
12
+ * Separate from `RouterPager` so the behaviour can be asserted without
13
+ * mounting a router. `smoothScrollToTop` suppresses TanStack's instant
14
+ * `resetScroll` in favour of an animated one, which makes a missing
15
+ * implementation silent — the page simply does not move. That is what
16
+ * happened to every pager carrying the flag.
17
+ *
18
+ * Honours `prefers-reduced-motion`: those readers still arrive at the top,
19
+ * without the travel.
20
+ */
21
+ export function scrollWindowToTop(): void {
22
+ if (typeof window === 'undefined') return
23
+
24
+ // `matchMedia` is absent in some test and server-ish environments; treating
25
+ // that as "no preference expressed" keeps the scroll working either way.
26
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
27
+ window.scrollTo({ top: 0, behavior: prefersReducedMotion === true ? 'auto' : 'smooth' })
28
+ }
@@ -6,7 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
- import type { CollectionDefinition } from '@byline/core'
9
+ import type { MultiCollectionDefinition } from '@byline/core'
10
10
  import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
11
11
  import { useTranslation } from '@byline/i18n/react'
12
12
  import { Container, Section } from '@byline/ui/react'
@@ -32,7 +32,7 @@ export const ApiView = ({
32
32
  contentLocales,
33
33
  defaultContentLocale,
34
34
  }: {
35
- collectionDefinition: CollectionDefinition
35
+ collectionDefinition: MultiCollectionDefinition
36
36
  initialData: AnyCollectionSchemaTypes['UpdateType']
37
37
  /** Currently active locale from the route search param (undefined → all). */
38
38
  locale?: string
@@ -9,7 +9,7 @@
9
9
  import { useState } from 'react'
10
10
 
11
11
  import { FormRenderer } from '@byline/admin/react'
12
- import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
12
+ import type { CollectionAdminConfig, MultiCollectionDefinition } from '@byline/core'
13
13
  import { useTranslation } from '@byline/i18n/react'
14
14
  import { Container, Section, useToastManager } from '@byline/ui/react'
15
15
 
@@ -30,7 +30,7 @@ export const CreateView = ({
30
30
  initialData,
31
31
  from,
32
32
  }: {
33
- collectionDefinition: CollectionDefinition
33
+ collectionDefinition: MultiCollectionDefinition
34
34
  adminConfig?: CollectionAdminConfig
35
35
  // biome-ignore lint/suspicious/noExplicitAny: shape is collection-specific
36
36
  initialData?: Record<string, any>
@@ -105,6 +105,10 @@ export const CreateView = ({
105
105
  status: 'failed',
106
106
  message: description,
107
107
  })
108
+
109
+ // Rethrow so FormRenderer keeps the form dirty. The toast above has
110
+ // already told the editor what went wrong.
111
+ throw err
108
112
  }
109
113
  }
110
114
 
@@ -9,7 +9,7 @@
9
9
  import { useState } from 'react'
10
10
 
11
11
  import { FormRenderer } from '@byline/admin/react'
12
- import type { CollectionAdminConfig, CollectionDefinition } from '@byline/core'
12
+ import type { CollectionAdminConfig, MultiCollectionDefinition } from '@byline/core'
13
13
  import {
14
14
  getDefaultStatus,
15
15
  getWorkflow,
@@ -56,7 +56,7 @@ export const EditView = ({
56
56
  defaultContentLocale,
57
57
  returnSearch,
58
58
  }: {
59
- collectionDefinition: CollectionDefinition
59
+ collectionDefinition: MultiCollectionDefinition
60
60
  adminConfig?: CollectionAdminConfig
61
61
  initialData: AnyCollectionSchemaTypes['UpdateType']
62
62
  locale?: string
@@ -623,6 +623,10 @@ export const EditView = ({
623
623
  })
624
624
 
625
625
  setEditState({ status: 'failed', message: description })
626
+
627
+ // Rethrow so FormRenderer keeps the form dirty. The toast above has
628
+ // already told the editor what went wrong.
629
+ throw err
626
630
  }
627
631
  }
628
632
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * HistoryView — list of past versions for a collection document.
2
+ * Shared version-history presentation for collection documents and singletons.
3
3
  *
4
4
  * Override handles:
5
5
  * .byline-coll-history-head — title + view-menu row