@byline/host-tanstack-start 4.16.0 → 4.17.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 (146) 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/collections/api.d.ts +2 -2
  5. package/dist/admin-shell/collections/create.d.ts +2 -2
  6. package/dist/admin-shell/collections/create.js +1 -0
  7. package/dist/admin-shell/collections/edit.d.ts +2 -2
  8. package/dist/admin-shell/collections/edit.js +1 -0
  9. package/dist/admin-shell/collections/history.d.ts +4 -12
  10. package/dist/admin-shell/collections/history.js +112 -303
  11. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  12. package/dist/admin-shell/collections/reindex-button.js +5 -1
  13. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  14. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  15. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  16. package/dist/admin-shell/collections/version-history.js +259 -0
  17. package/dist/admin-shell/singletons/history.d.ts +19 -0
  18. package/dist/admin-shell/singletons/history.js +170 -0
  19. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  20. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  21. package/dist/admin-shell/singletons/preview-link.js +54 -0
  22. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  23. package/dist/admin-shell/singletons/view-menu.js +116 -0
  24. package/dist/admin-shell/singletons/view.d.ts +18 -0
  25. package/dist/admin-shell/singletons/view.js +259 -0
  26. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  27. package/dist/integrations/api-utils.d.ts +18 -4
  28. package/dist/integrations/api-utils.js +10 -3
  29. package/dist/routes/create-admin-dashboard-route.js +2 -2
  30. package/dist/routes/create-collection-api-route.js +3 -3
  31. package/dist/routes/create-collection-create-route.js +4 -3
  32. package/dist/routes/create-collection-edit-route.js +4 -3
  33. package/dist/routes/create-collection-history-route.js +4 -3
  34. package/dist/routes/create-collection-list-route.js +4 -3
  35. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  36. package/dist/routes/create-singleton-history-route.js +99 -0
  37. package/dist/routes/create-singleton-route.d.ts +35 -0
  38. package/dist/routes/create-singleton-route.js +130 -0
  39. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  40. package/dist/routes/get-multi-collection-definition.js +6 -0
  41. package/dist/routes/get-singleton-definition.d.ts +10 -0
  42. package/dist/routes/get-singleton-definition.js +6 -0
  43. package/dist/routes/index.d.ts +2 -0
  44. package/dist/routes/index.js +2 -0
  45. package/dist/server-fns/actors.d.ts +16 -0
  46. package/dist/server-fns/actors.js +25 -0
  47. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  48. package/dist/server-fns/admin-document-presentation.js +32 -0
  49. package/dist/server-fns/collections/actors.d.ts +1 -10
  50. package/dist/server-fns/collections/actors.js +1 -25
  51. package/dist/server-fns/collections/get.js +9 -26
  52. package/dist/server-fns/collections/reorder.js +1 -1
  53. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  54. package/dist/server-fns/collections/upload.js +3 -3
  55. package/dist/server-fns/collections/utils.d.ts +1 -7
  56. package/dist/server-fns/collections/utils.js +1 -4
  57. package/dist/server-fns/serialise.d.ts +14 -0
  58. package/dist/server-fns/serialise.js +4 -0
  59. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  60. package/dist/server-fns/singleton-document-read.js +10 -0
  61. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  62. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  63. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  64. package/dist/server-fns/singletons/change-status.js +7 -0
  65. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  66. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  67. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  68. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  69. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  70. package/dist/server-fns/singletons/find-by-version.js +9 -0
  71. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  72. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  73. package/dist/server-fns/singletons/get.d.ts +24 -0
  74. package/dist/server-fns/singletons/get.js +6 -0
  75. package/dist/server-fns/singletons/history.d.ts +31 -0
  76. package/dist/server-fns/singletons/history.js +22 -0
  77. package/dist/server-fns/singletons/index.d.ts +19 -0
  78. package/dist/server-fns/singletons/index.js +12 -0
  79. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  80. package/dist/server-fns/singletons/restore-version.js +7 -0
  81. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  82. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  83. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  84. package/dist/server-fns/singletons/unpublish.js +7 -0
  85. package/dist/server-fns/singletons/update.d.ts +14 -0
  86. package/dist/server-fns/singletons/update.js +14 -0
  87. package/dist/vitest-jsdom.test.d.ts +8 -0
  88. package/package.json +24 -18
  89. package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -13
  90. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  91. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  92. package/src/admin-shell/collections/api.tsx +2 -2
  93. package/src/admin-shell/collections/create.tsx +6 -2
  94. package/src/admin-shell/collections/edit.tsx +6 -2
  95. package/src/admin-shell/collections/history.module.css +1 -1
  96. package/src/admin-shell/collections/history.tsx +162 -442
  97. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  98. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  99. package/src/admin-shell/collections/version-history.tsx +349 -0
  100. package/src/admin-shell/singletons/history.test.tsx +407 -0
  101. package/src/admin-shell/singletons/history.tsx +185 -0
  102. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  103. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  104. package/src/admin-shell/singletons/view.test.tsx +400 -0
  105. package/src/admin-shell/singletons/view.tsx +351 -0
  106. package/src/integrations/api-utils.ts +34 -9
  107. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  108. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  109. package/src/routes/create-collection-api-route.tsx +4 -4
  110. package/src/routes/create-collection-create-route.tsx +5 -8
  111. package/src/routes/create-collection-edit-route.tsx +5 -4
  112. package/src/routes/create-collection-history-route.tsx +5 -8
  113. package/src/routes/create-collection-list-route.tsx +5 -8
  114. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  115. package/src/routes/create-singleton-history-route.tsx +116 -0
  116. package/src/routes/create-singleton-route.test.node.ts +172 -0
  117. package/src/routes/create-singleton-route.tsx +176 -0
  118. package/src/routes/get-multi-collection-definition.ts +16 -0
  119. package/src/routes/get-singleton-definition.ts +16 -0
  120. package/src/routes/index.ts +2 -0
  121. package/src/server-fns/actors.ts +43 -0
  122. package/src/server-fns/admin-document-presentation.ts +78 -0
  123. package/src/server-fns/collections/actors.ts +1 -52
  124. package/src/server-fns/collections/get.ts +10 -49
  125. package/src/server-fns/collections/reorder.ts +1 -1
  126. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  127. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  128. package/src/server-fns/collections/upload.ts +7 -3
  129. package/src/server-fns/collections/utils.ts +1 -9
  130. package/src/server-fns/serialise.ts +17 -0
  131. package/src/server-fns/singleton-document-read.ts +32 -0
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  133. package/src/server-fns/singletons/change-status.ts +19 -0
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  135. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  136. package/src/server-fns/singletons/find-by-version.ts +23 -0
  137. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  138. package/src/server-fns/singletons/get.ts +32 -0
  139. package/src/server-fns/singletons/history.ts +41 -0
  140. package/src/server-fns/singletons/index.ts +20 -0
  141. package/src/server-fns/singletons/restore-version.ts +19 -0
  142. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  143. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  144. package/src/server-fns/singletons/unpublish.ts +19 -0
  145. package/src/server-fns/singletons/update.ts +31 -0
  146. package/src/vitest-jsdom.test.tsx +27 -0
@@ -0,0 +1,407 @@
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 type { MultiCollectionDefinition, SingletonDefinition } from '@byline/core'
13
+ import { createRoot, type Root } from 'react-dom/client'
14
+ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ const mocks = vi.hoisted(() => ({
17
+ navigate: vi.fn(),
18
+ invalidate: vi.fn(async () => {}),
19
+ restoreSingleton: vi.fn(async () => ({})),
20
+ restoreCollection: vi.fn(async () => ({})),
21
+ findSingletonByVersion: vi.fn(async () => ({})),
22
+ pagerProps: [] as Array<{ componentName: string; page: number; count: number }>,
23
+ diffModalProps: [] as Array<Record<string, unknown>>,
24
+ location: {
25
+ pathname: '/admin/singletons/site-settings/history',
26
+ search: {} as Record<string, unknown>,
27
+ },
28
+ }))
29
+
30
+ vi.mock('@tanstack/react-router', () => ({
31
+ useRouter: () => ({ invalidate: mocks.invalidate }),
32
+ useRouterState: ({ select }: { select: (state: unknown) => unknown }) =>
33
+ select({ location: mocks.location }),
34
+ useParams: () => ({ collection: 'articles', id: 'document-article' }),
35
+ }))
36
+
37
+ vi.mock('@byline/admin/react', () => ({
38
+ AdminTabs: ({ tabs }: { tabs: Array<{ name: string; label: string }> }) => (
39
+ <div data-testid="admin-tabs">
40
+ {tabs.map((tab) => (
41
+ <span key={tab.name}>{tab.label}</span>
42
+ ))}
43
+ </div>
44
+ ),
45
+ renderFormatted: (value: unknown) => String(value ?? ''),
46
+ StatusBadge: ({ status }: { status: string }) => <span>{status}</span>,
47
+ DiffModal: (props: Record<string, unknown>) => {
48
+ mocks.diffModalProps.push(props)
49
+ return <div data-testid="diff-modal" />
50
+ },
51
+ }))
52
+
53
+ vi.mock('@byline/admin/services', () => ({
54
+ useBylineAdminServices: () => ({
55
+ getCollectionDocumentVersion: vi.fn(async () => ({})),
56
+ }),
57
+ }))
58
+
59
+ vi.mock('@byline/i18n/react', () => ({
60
+ useTranslation: () => ({
61
+ t: (key: string, values?: Record<string, string | number>) => {
62
+ if (key === 'collections.history.restoreButton') return 'Restore'
63
+ if (key === 'collections.history.restoreButtonTitle')
64
+ return `Restore this version as ${values?.status}`
65
+ if (key === 'collections.restore.warning') return `New version status: ${values?.status}`
66
+ if (key === 'collections.restore.confirmButton') return `Restore as ${values?.status}`
67
+ if (key === 'collections.history.title') return `${values?.label} History`
68
+ if (key === 'collections.history.audit.createdBy') return `by ${values?.label}`
69
+ return key
70
+ },
71
+ }),
72
+ }))
73
+
74
+ vi.mock('@byline/ui/react', () => {
75
+ type Props = React.HTMLAttributes<HTMLElement> & { children?: React.ReactNode }
76
+ const Div = ({ children, ...props }: Props) => <div {...props}>{children}</div>
77
+ const Button = ({ children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
78
+ <button {...props}>{children}</button>
79
+ )
80
+ const Modal = ({ isOpen, children }: { isOpen: boolean; children?: React.ReactNode }) =>
81
+ isOpen ? <div data-testid="modal">{children}</div> : null
82
+ Modal.Container = Div
83
+ Modal.Header = Div
84
+ Modal.Content = Div
85
+ const Table = ({ children }: { children?: React.ReactNode }) => <table>{children}</table>
86
+ Table.Container = Div
87
+ Table.Header = ({ children }: { children?: React.ReactNode }) => <thead>{children}</thead>
88
+ Table.Body = ({ children }: { children?: React.ReactNode }) => <tbody>{children}</tbody>
89
+ Table.Row = ({ children, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
90
+ <tr {...props}>{children}</tr>
91
+ )
92
+ Table.Cell = ({ children, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
93
+ <td {...props}>{children}</td>
94
+ )
95
+ return {
96
+ Alert: Div,
97
+ Button,
98
+ CloseIcon: () => null,
99
+ Container: Div,
100
+ IconButton: Button,
101
+ LoaderEllipsis: () => <span>loading</span>,
102
+ Modal,
103
+ Section: Div,
104
+ Select: () => <select aria-label="page size" />,
105
+ Table,
106
+ }
107
+ })
108
+
109
+ vi.mock('../../routes/admin-path.js', () => ({
110
+ getAdminRouteId: () => '/admin/collections/$collection/$id/history',
111
+ getAdminRoutePath: (...parts: string[]) => `/admin/${parts.join('/')}`,
112
+ }))
113
+
114
+ vi.mock('../../server-fns/singletons/index.js', () => ({
115
+ findSingletonByVersion: mocks.findSingletonByVersion,
116
+ restoreSingletonVersion: mocks.restoreSingleton,
117
+ }))
118
+
119
+ vi.mock('../../server-fns/collections/index.js', () => ({
120
+ restoreDocumentVersion: mocks.restoreCollection,
121
+ }))
122
+
123
+ vi.mock('../chrome/loose-router.js', () => ({
124
+ Link: ({ children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
125
+ <a {...props}>{children}</a>
126
+ ),
127
+ useNavigate: () => mocks.navigate,
128
+ }))
129
+
130
+ vi.mock('../chrome/router-pager.js', () => ({
131
+ RouterPager: (props: { componentName: string; page: number; count: number }) => {
132
+ mocks.pagerProps.push(props)
133
+ return <div data-pager={props.componentName} />
134
+ },
135
+ }))
136
+
137
+ vi.mock('../chrome/th-sortable.js', () => ({
138
+ TableHeadingCellSortable: ({ label }: { label: string }) => <th>{label}</th>,
139
+ }))
140
+
141
+ vi.mock('../collections/document-history.js', () => ({
142
+ DocumentHistoryView: () => <div data-testid="document-audit-log" />,
143
+ }))
144
+ vi.mock('../collections/view-menu.js', () => ({ ViewMenu: () => <div /> }))
145
+ vi.mock('./view-menu.js', () => ({ SingletonViewMenu: () => <div /> }))
146
+
147
+ import { HistoryView } from '../collections/history.js'
148
+ import { SingletonHistoryView } from './history.js'
149
+ import type { VersionHistoryData } from '../collections/version-history.js'
150
+
151
+ const singletonDefinition = {
152
+ singleton: true,
153
+ path: 'site-settings',
154
+ label: 'Site settings',
155
+ workflow: { statuses: [{ name: 'published', label: 'Published' }], defaultStatus: 'published' },
156
+ fields: [],
157
+ } as unknown as SingletonDefinition
158
+
159
+ const collectionDefinition = {
160
+ path: 'articles',
161
+ labels: { singular: 'Article', plural: 'Articles' },
162
+ useAsTitle: 'title',
163
+ fields: [{ name: 'title', label: 'Title', type: 'text' }],
164
+ } as MultiCollectionDefinition
165
+
166
+ const data: VersionHistoryData = {
167
+ docs: [
168
+ {
169
+ id: 'document-settings',
170
+ versionId: 'version-old',
171
+ status: 'draft',
172
+ createdAt: '2026-08-25T10:00:00.000Z',
173
+ createdBy: 'actor-1',
174
+ eventType: 'update',
175
+ fields: { title: 'Old' },
176
+ },
177
+ {
178
+ id: 'document-settings',
179
+ versionId: 'version-current',
180
+ status: 'published',
181
+ createdAt: '2026-08-26T10:00:00.000Z',
182
+ createdBy: 'actor-2',
183
+ eventType: 'update',
184
+ fields: { title: 'Current' },
185
+ },
186
+ ],
187
+ meta: { total: 2, page: 1, pageSize: 15, totalPages: 1, desc: false },
188
+ actors: {
189
+ 'actor-1': { label: 'Ada Editor' },
190
+ 'actor-2': { label: 'Grace Editor' },
191
+ },
192
+ }
193
+
194
+ const currentDocument = {
195
+ id: 'document-settings',
196
+ versionId: 'version-current',
197
+ status: 'published',
198
+ fields: { title: 'Current' },
199
+ }
200
+
201
+ let container: HTMLDivElement
202
+ let root: Root
203
+
204
+ function render(element: React.ReactNode) {
205
+ container = document.createElement('div')
206
+ document.body.append(container)
207
+ root = createRoot(container)
208
+ act(() => root.render(element))
209
+ }
210
+
211
+ function dispose() {
212
+ act(() => root.unmount())
213
+ container.remove()
214
+ }
215
+
216
+ afterAll(() => {
217
+ delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
218
+ .IS_REACT_ACT_ENVIRONMENT
219
+ })
220
+
221
+ describe('singleton version history', () => {
222
+ beforeEach(() => {
223
+ vi.clearAllMocks()
224
+ ;(
225
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
226
+ ).IS_REACT_ACT_ENVIRONMENT = true
227
+ mocks.location.pathname = '/admin/singletons/site-settings/history'
228
+ mocks.location.search = {}
229
+ mocks.pagerProps.length = 0
230
+ mocks.diffModalProps.length = 0
231
+ })
232
+
233
+ it('renders an explicit singleton restore column without collection title configuration', async () => {
234
+ render(
235
+ <SingletonHistoryView
236
+ singletonDefinition={singletonDefinition}
237
+ data={data}
238
+ currentDocument={currentDocument}
239
+ contentLocales={[]}
240
+ defaultContentLocale="en"
241
+ workflowStatuses={[]}
242
+ />
243
+ )
244
+
245
+ expect(container.textContent).toContain('draft')
246
+ expect(container.textContent).toContain('published')
247
+ expect(
248
+ [...container.querySelectorAll('.byline-singleton-history-actor-cell')].map(
249
+ (cell) => cell.textContent
250
+ )
251
+ ).toEqual(['Ada Editor', 'Grace Editor'])
252
+ expect(container.textContent).not.toContain('collections.history.tabs.document')
253
+ const restoreButtons = [...container.querySelectorAll('button')].filter(
254
+ (button) => button.textContent === 'Restore'
255
+ )
256
+ expect(restoreButtons).toHaveLength(1)
257
+ expect(restoreButtons[0]?.title).toBe('Restore this version as Published')
258
+
259
+ await act(async () => restoreButtons[0]?.click())
260
+ expect(container.textContent).toContain('New version status: Published')
261
+ const confirm = [...container.querySelectorAll('button')].find(
262
+ (button) => button.textContent === 'Restore as Published'
263
+ )
264
+ await act(async () => confirm?.click())
265
+
266
+ expect(mocks.restoreSingleton).toHaveBeenCalledWith({
267
+ data: { singleton: 'site-settings', versionId: 'version-old' },
268
+ })
269
+ expect(mocks.invalidate).toHaveBeenCalledTimes(1)
270
+ expect(mocks.navigate).toHaveBeenLastCalledWith({
271
+ to: '/admin/singletons/$singleton',
272
+ params: { singleton: 'site-settings' },
273
+ search: {},
274
+ })
275
+ dispose()
276
+ })
277
+
278
+ it('wires the requested page and total pages into both shared pagers', () => {
279
+ render(
280
+ <SingletonHistoryView
281
+ singletonDefinition={singletonDefinition}
282
+ data={{
283
+ ...data,
284
+ meta: { ...data.meta, page: 3, pageSize: 30, total: 95, totalPages: 4 },
285
+ }}
286
+ currentDocument={currentDocument}
287
+ contentLocales={[]}
288
+ defaultContentLocale="en"
289
+ workflowStatuses={[]}
290
+ />
291
+ )
292
+
293
+ expect(mocks.pagerProps).toEqual([
294
+ expect.objectContaining({ componentName: 'pagerTop', page: 3, count: 4 }),
295
+ expect.objectContaining({ componentName: 'pagerBottom', page: 3, count: 4 }),
296
+ ])
297
+ dispose()
298
+ })
299
+
300
+ it('opens comparison with the singleton-bound historical version loader', async () => {
301
+ render(
302
+ <SingletonHistoryView
303
+ singletonDefinition={singletonDefinition}
304
+ data={data}
305
+ currentDocument={currentDocument}
306
+ contentLocales={[]}
307
+ defaultContentLocale="en"
308
+ workflowStatuses={[]}
309
+ />
310
+ )
311
+
312
+ const compare = container.querySelector<HTMLButtonElement>(
313
+ 'button[aria-label="collections.history.compareAriaLabel"]'
314
+ )
315
+ await act(async () => {
316
+ compare?.click()
317
+ await Promise.resolve()
318
+ })
319
+
320
+ expect(container.querySelector('[data-testid="diff-modal"]')).not.toBeNull()
321
+ expect(mocks.diffModalProps).toHaveLength(1)
322
+ expect(mocks.diffModalProps[0]).toMatchObject({
323
+ collection: 'site-settings',
324
+ documentId: 'document-settings',
325
+ versionId: 'version-old',
326
+ currentDocument,
327
+ })
328
+
329
+ const loadHistoricalVersion = mocks.diffModalProps[0]?.loadHistoricalVersion as (
330
+ resourcePath: string,
331
+ documentId: string,
332
+ versionId: string,
333
+ locale?: string
334
+ ) => Promise<Record<string, unknown>>
335
+ await loadHistoricalVersion('site-settings', 'document-settings', 'version-old', 'fr')
336
+ expect(mocks.findSingletonByVersion).toHaveBeenCalledWith({
337
+ data: { singleton: 'site-settings', versionId: 'version-old', locale: 'fr' },
338
+ })
339
+ dispose()
340
+ })
341
+
342
+ it('renders an empty unmaterialised history without an audit-log tab or restore action', () => {
343
+ render(
344
+ <SingletonHistoryView
345
+ singletonDefinition={singletonDefinition}
346
+ data={{
347
+ docs: [],
348
+ meta: { total: 0, page: 3, pageSize: 30, totalPages: 0 },
349
+ actors: {},
350
+ }}
351
+ currentDocument={null}
352
+ contentLocales={[]}
353
+ defaultContentLocale="en"
354
+ workflowStatuses={[]}
355
+ />
356
+ )
357
+
358
+ expect(container.textContent).toContain('Site settings History')
359
+ expect(container.textContent).not.toContain('collections.history.tabs.document')
360
+ expect(container.querySelectorAll('button')).toHaveLength(0)
361
+ dispose()
362
+ })
363
+
364
+ it('keeps the collection audit tab and title-driven restore behavior', async () => {
365
+ mocks.location.pathname = '/admin/collections/articles/document-article/history'
366
+ render(
367
+ <HistoryView
368
+ collectionDefinition={collectionDefinition}
369
+ adminConfig={{
370
+ slug: 'articles',
371
+ columns: [{ fieldName: 'title', label: 'Title' }],
372
+ }}
373
+ data={data as never}
374
+ auditLog={{ entries: [], meta: { total: 0, page: 1, pageSize: 100, totalPages: 0 } }}
375
+ currentDocument={{ ...currentDocument, id: 'document-article' }}
376
+ contentLocales={[]}
377
+ defaultContentLocale="en"
378
+ />
379
+ )
380
+
381
+ expect(container.textContent).toContain('collections.history.tabs.document')
382
+ const restore = [...container.querySelectorAll('button')].find(
383
+ (button) => button.textContent === 'Restore'
384
+ )
385
+ expect(restore).toBeDefined()
386
+ expect(restore?.title).toBe('Restore this version as Draft')
387
+ await act(async () => restore?.click())
388
+ expect(container.textContent).toContain('New version status: Draft')
389
+ const confirm = [...container.querySelectorAll('button')].find(
390
+ (button) => button.textContent === 'Restore as Draft'
391
+ )
392
+ await act(async () => confirm?.click())
393
+
394
+ expect(mocks.restoreCollection).toHaveBeenCalledWith({
395
+ data: {
396
+ collection: 'articles',
397
+ id: 'document-article',
398
+ versionId: 'version-old',
399
+ },
400
+ })
401
+ expect(mocks.navigate).toHaveBeenLastCalledWith({
402
+ to: '/admin/collections/$collection/$id',
403
+ params: { collection: 'articles', id: 'document-article' },
404
+ })
405
+ dispose()
406
+ })
407
+ })
@@ -0,0 +1,185 @@
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 { useRouter, useRouterState } from '@tanstack/react-router'
10
+
11
+ import { StatusBadge } from '@byline/admin/react'
12
+ import type {
13
+ SingletonAdminConfig,
14
+ SingletonDefinition,
15
+ SingletonPreviewDocument,
16
+ WorkflowStatus,
17
+ } from '@byline/core'
18
+ import { useTranslation } from '@byline/i18n/react'
19
+ import { Button, Container, Section, Table } from '@byline/ui/react'
20
+ import cx from 'clsx'
21
+
22
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
23
+ import {
24
+ findSingletonByVersion,
25
+ restoreSingletonVersion,
26
+ } from '../../server-fns/singletons/index.js'
27
+ import { useNavigate } from '../chrome/loose-router.js'
28
+ import historyStyles from '../collections/history.module.css'
29
+ import {
30
+ HistoryStats,
31
+ VersionHistoryCore,
32
+ type VersionHistoryData,
33
+ type VersionHistoryRowPresentation,
34
+ } from '../collections/version-history.js'
35
+ import { SingletonViewMenu } from './view-menu.js'
36
+ import type { ContentLocaleOption } from '../collections/view-menu.js'
37
+
38
+ export function SingletonHistoryView({
39
+ singletonDefinition,
40
+ adminConfig,
41
+ data,
42
+ currentDocument,
43
+ contentLocales,
44
+ defaultContentLocale,
45
+ workflowStatuses,
46
+ }: {
47
+ singletonDefinition: SingletonDefinition
48
+ adminConfig?: SingletonAdminConfig
49
+ data: VersionHistoryData
50
+ currentDocument?: Record<string, unknown> | null
51
+ contentLocales: ReadonlyArray<ContentLocaleOption>
52
+ defaultContentLocale: string
53
+ workflowStatuses: WorkflowStatus[]
54
+ }) {
55
+ const singleton = singletonDefinition.path
56
+ const navigate = useNavigate()
57
+ const router = useRouter()
58
+ const location = useRouterState({ select: (state) => state.location })
59
+ const locale = (location.search as { locale?: string }).locale
60
+ const { t } = useTranslation('byline-admin')
61
+
62
+ function handlePageSizeChange(pageSize: number): void {
63
+ const params = structuredClone(location.search)
64
+ delete params.page
65
+ params.page_size = pageSize
66
+ navigate({
67
+ to: getAdminRoutePath('singletons', '$singleton', 'history'),
68
+ params: { singleton },
69
+ search: params,
70
+ })
71
+ }
72
+
73
+ const rowPresentation: VersionHistoryRowPresentation = {
74
+ headers: (
75
+ <>
76
+ <th scope="col">{t('collections.history.statusColumn')}</th>
77
+ <th scope="col">{t('collections.documentHistory.colWhen')}</th>
78
+ <th scope="col">{t('collections.documentHistory.colActor')}</th>
79
+ <th
80
+ scope="col"
81
+ className={cx('byline-coll-history-col-restore', historyStyles.colRestore)}
82
+ />
83
+ </>
84
+ ),
85
+ auditColSpan: 4,
86
+ renderCells: ({
87
+ document,
88
+ versionId,
89
+ currentVersionId,
90
+ actorLabel,
91
+ restoreStatusLabel,
92
+ openRestore,
93
+ }) => (
94
+ <>
95
+ <Table.Cell className="byline-singleton-history-status-cell">
96
+ <StatusBadge status={document.status} workflowStatuses={workflowStatuses} />
97
+ </Table.Cell>
98
+ <Table.Cell className="byline-singleton-history-created-cell">
99
+ {new Date(document.createdAt).toLocaleString()}
100
+ </Table.Cell>
101
+ <Table.Cell className="byline-singleton-history-actor-cell">{actorLabel}</Table.Cell>
102
+ <Table.Cell className={cx('byline-coll-history-restore-cell', historyStyles.restoreCell)}>
103
+ {versionId && versionId !== currentVersionId ? (
104
+ <Button
105
+ type="button"
106
+ variant="outlined"
107
+ size="xs"
108
+ intent="noeffect"
109
+ onClick={openRestore}
110
+ className={cx('byline-coll-history-restore-button', historyStyles.restoreButton)}
111
+ title={t('collections.history.restoreButtonTitle', {
112
+ status: restoreStatusLabel,
113
+ })}
114
+ >
115
+ {t('collections.history.restoreButton')}
116
+ </Button>
117
+ ) : null}
118
+ </Table.Cell>
119
+ </>
120
+ ),
121
+ }
122
+
123
+ const documentId =
124
+ currentDocument && typeof currentDocument.id === 'string' ? currentDocument.id : ''
125
+
126
+ return (
127
+ <>
128
+ <Section>
129
+ <Container>
130
+ <div className={cx('byline-coll-history-head', historyStyles.head)}>
131
+ <h2 className={cx('byline-coll-history-title', historyStyles.title)}>
132
+ {t('collections.history.title', { label: singletonDefinition.label })}{' '}
133
+ <HistoryStats total={data.meta.total} />
134
+ </h2>
135
+ <SingletonViewMenu
136
+ singleton={singleton}
137
+ activeView="history"
138
+ locale={locale}
139
+ contentLocales={contentLocales}
140
+ defaultContentLocale={defaultContentLocale}
141
+ adminConfig={adminConfig}
142
+ doc={
143
+ currentDocument == null
144
+ ? undefined
145
+ : (currentDocument as unknown as SingletonPreviewDocument)
146
+ }
147
+ />
148
+ </div>
149
+ </Container>
150
+ </Section>
151
+ <Section>
152
+ <Container>
153
+ <VersionHistoryCore
154
+ data={data}
155
+ resourceDefinition={singletonDefinition}
156
+ resourcePath={singleton}
157
+ documentId={documentId}
158
+ currentDocument={currentDocument}
159
+ locale={locale}
160
+ rowPresentation={rowPresentation}
161
+ loadHistoricalVersion={async (_path, _documentId, versionId, versionLocale) => {
162
+ const historical = await findSingletonByVersion({
163
+ data: { singleton, versionId, locale: versionLocale },
164
+ })
165
+ if (historical == null) throw new Error('Singleton version not found')
166
+ return historical as unknown as Record<string, unknown>
167
+ }}
168
+ onPageSizeChange={handlePageSizeChange}
169
+ restoreVersion={(versionId) =>
170
+ restoreSingletonVersion({ data: { singleton, versionId } })
171
+ }
172
+ onRestoreComplete={async () => {
173
+ await router.invalidate()
174
+ navigate({
175
+ to: getAdminRoutePath('singletons', '$singleton'),
176
+ params: { singleton },
177
+ search: locale ? { locale } : {},
178
+ })
179
+ }}
180
+ />
181
+ </Container>
182
+ </Section>
183
+ </>
184
+ )
185
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * This Source Code is subject to the terms of the Mozilla Public
5
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
7
+ *
8
+ * Copyright (c) Infonomic Company Limited
9
+ */
10
+
11
+ import { useState } from 'react'
12
+
13
+ import type { SingletonAdminConfig, SingletonPreviewDocument } from '@byline/core'
14
+ import { useTranslation } from '@byline/i18n/react'
15
+ import { ExternalLinkIcon, IconButton, useToastManager } from '@byline/ui/react'
16
+ import cx from 'clsx'
17
+
18
+ import { enablePreviewModeFn } from '../../server-fns/preview/index.js'
19
+
20
+ /**
21
+ * Explicit-preview-only singleton affordance. A singleton's stored path is
22
+ * internal identity metadata, so unlike collection previews there is no
23
+ * schema or conventional path fallback.
24
+ */
25
+ export function SingletonPreviewLink({
26
+ doc,
27
+ adminConfig,
28
+ locale,
29
+ className,
30
+ }: {
31
+ doc: SingletonPreviewDocument
32
+ adminConfig: SingletonAdminConfig
33
+ locale?: string
34
+ className?: string
35
+ }) {
36
+ const toastManager = useToastManager()
37
+ const { t } = useTranslation('byline-admin')
38
+ const [busy, setBusy] = useState(false)
39
+ const url = adminConfig.preview?.url(doc, { locale }) ?? null
40
+
41
+ if (url == null) return null
42
+
43
+ const handleClick = async () => {
44
+ if (busy) return
45
+ setBusy(true)
46
+ try {
47
+ await enablePreviewModeFn()
48
+ window.location.assign(url)
49
+ } catch (err) {
50
+ toastManager.add({
51
+ title: t('collections.preview.toastTitle'),
52
+ description: t('collections.preview.failedDescription', {
53
+ message: (err as Error).message,
54
+ }),
55
+ data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
56
+ })
57
+ } finally {
58
+ setBusy(false)
59
+ }
60
+ }
61
+
62
+ return (
63
+ <IconButton
64
+ className={cx('byline-preview-link', className)}
65
+ size="xs"
66
+ variant="text"
67
+ disabled={busy}
68
+ onClick={handleClick}
69
+ aria-label={t('collections.preview.openAriaLabel')}
70
+ title={t('collections.preview.title')}
71
+ >
72
+ <ExternalLinkIcon width="20px" height="20px" className="byline-preview-link-icon" />
73
+ </IconButton>
74
+ )
75
+ }