@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
@@ -10,12 +10,8 @@ import { useEffect, useRef } from 'react'
10
10
  import { createFileRoute, notFound } from '@tanstack/react-router'
11
11
 
12
12
  import { applyRelationColumnFormatters } from '@byline/admin/react'
13
- import type { CollectionDefinition } from '@byline/core'
14
- import {
15
- getCollectionAdminConfig,
16
- getCollectionDefinition,
17
- getWorkflowStatuses,
18
- } from '@byline/core'
13
+ import type { MultiCollectionDefinition } from '@byline/core'
14
+ import { getCollectionAdminConfig, getWorkflowStatuses } from '@byline/core'
19
15
  import { useTranslation } from '@byline/i18n/react'
20
16
  import { useToastManager } from '@byline/ui/react'
21
17
  import type { z } from 'zod'
@@ -31,6 +27,7 @@ import {
31
27
  reorderCollectionDocument,
32
28
  } from '../server-fns/collections/index.js'
33
29
  import { getAdminRoutePath } from './admin-path.js'
30
+ import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
34
31
  import { collectionListSearchSchema as searchSchema } from './list-return-state.js'
35
32
  import type { CollectionTreeRow } from '../server-fns/collections/tree.js'
36
33
 
@@ -62,7 +59,7 @@ export function createCollectionListRoute(path: string) {
62
59
  status?: string
63
60
  }
64
61
  }) => {
65
- const collectionDef = getCollectionDefinition(params.collection)
62
+ const collectionDef = getMultiCollectionDefinition(params.collection)
66
63
  if (!collectionDef) {
67
64
  throw notFound()
68
65
  }
@@ -108,7 +105,7 @@ export function createCollectionListRoute(path: string) {
108
105
  const { collection } = Route.useParams() as { collection: string }
109
106
  const search = Route.useSearch() as z.infer<typeof searchSchema>
110
107
  const navigate = useNavigate()
111
- const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
108
+ const collectionDef = getMultiCollectionDefinition(collection) as MultiCollectionDefinition
112
109
  const adminConfig = getCollectionAdminConfig(collection)
113
110
  // Apply the built-in relation formatter to any relation column without an
114
111
  // explicit one, so relation cells render the target's title (the list
@@ -0,0 +1,148 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ getSingletonHistory: vi.fn(),
13
+ getSingleton: vi.fn(),
14
+ definition: {
15
+ singleton: true,
16
+ path: 'site-settings',
17
+ label: 'Site settings',
18
+ fields: [],
19
+ },
20
+ }))
21
+
22
+ vi.mock('@tanstack/react-router', () => ({
23
+ createFileRoute: () => (options: Record<string, unknown>) => ({ options }),
24
+ notFound: () => new Error('not found'),
25
+ }))
26
+
27
+ vi.mock('@byline/core', () => ({
28
+ getCollectionDefinition: (path: string) =>
29
+ path === mocks.definition.path ? mocks.definition : null,
30
+ isSingleton: (definition: { singleton?: boolean }) => definition.singleton === true,
31
+ getSingletonAdminConfig: () => null,
32
+ getWorkflowStatuses: () => [],
33
+ }))
34
+
35
+ vi.mock('../server-fns/singletons/index.js', () => ({
36
+ getSingletonHistory: mocks.getSingletonHistory,
37
+ getSingleton: mocks.getSingleton,
38
+ }))
39
+
40
+ vi.mock('../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js', () => ({
41
+ BreadcrumbsClient: () => null,
42
+ }))
43
+ vi.mock('../admin-shell/singletons/history.js', () => ({ SingletonHistoryView: () => null }))
44
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
45
+ vi.mock('./admin-path.js', () => ({ getAdminRoutePath: () => '/admin' }))
46
+ vi.mock('./get-content-locale-route-config.js', () => ({
47
+ getContentLocaleRouteConfig: () => ({ contentLocales: [], defaultContentLocale: 'en' }),
48
+ }))
49
+
50
+ import { createSingletonHistoryRoute } from './create-singleton-history-route.js'
51
+
52
+ interface RouteLoaderInput {
53
+ params: { singleton: string }
54
+ deps: {
55
+ page?: number
56
+ page_size?: number
57
+ order?: string
58
+ desc?: boolean
59
+ locale?: string
60
+ }
61
+ }
62
+
63
+ type RouteLoader = (input: RouteLoaderInput) => Promise<unknown>
64
+
65
+ function loader(): RouteLoader {
66
+ const route = createSingletonHistoryRoute('/admin/singletons/$singleton/history') as {
67
+ options: { loader: RouteLoader }
68
+ }
69
+ return route.options.loader
70
+ }
71
+
72
+ describe('singleton history route loader', () => {
73
+ beforeEach(() => {
74
+ vi.clearAllMocks()
75
+ mocks.getSingleton.mockResolvedValue(null)
76
+ })
77
+
78
+ it('loads a paginated materialised singleton history without a document id input', async () => {
79
+ const history = {
80
+ docs: [{ id: 'document-settings', versionId: 'version-1', status: 'draft' }],
81
+ meta: { total: 3, page: 2, pageSize: 1, totalPages: 3 },
82
+ actors: {},
83
+ }
84
+ const currentDocument = {
85
+ id: 'document-settings',
86
+ versionId: 'version-3',
87
+ status: 'draft',
88
+ fields: {},
89
+ }
90
+ mocks.getSingletonHistory.mockResolvedValue(history)
91
+ mocks.getSingleton.mockResolvedValue(currentDocument)
92
+
93
+ await expect(
94
+ loader()({
95
+ params: { singleton: 'site-settings' },
96
+ deps: { page: 2, page_size: 1, order: 'created_at', desc: true, locale: 'fr' },
97
+ })
98
+ ).resolves.toEqual({ history, currentDocument })
99
+
100
+ expect(mocks.getSingletonHistory).toHaveBeenCalledWith({
101
+ data: {
102
+ singleton: 'site-settings',
103
+ params: {
104
+ page: 2,
105
+ page_size: 1,
106
+ order: 'created_at',
107
+ desc: true,
108
+ locale: 'fr',
109
+ },
110
+ },
111
+ })
112
+ expect(mocks.getSingleton).toHaveBeenCalledWith({
113
+ data: { singleton: 'site-settings', locale: 'fr' },
114
+ })
115
+ })
116
+
117
+ it('preserves the requested empty FindResult envelope for an unmaterialised slot', async () => {
118
+ const history = {
119
+ docs: [],
120
+ meta: { total: 0, page: 4, pageSize: 30, totalPages: 0 },
121
+ actors: {},
122
+ }
123
+ mocks.getSingletonHistory.mockResolvedValue(history)
124
+
125
+ await expect(
126
+ loader()({
127
+ params: { singleton: 'site-settings' },
128
+ deps: { page: 4, page_size: 30 },
129
+ })
130
+ ).resolves.toEqual({ history, currentDocument: null })
131
+
132
+ expect(mocks.getSingleton).toHaveBeenCalledWith({
133
+ data: { singleton: 'site-settings', locale: 'all' },
134
+ })
135
+ })
136
+
137
+ it('preserves a kind-aware read denial from the singleton handle', async () => {
138
+ const forbidden = {
139
+ code: 'ERR_FORBIDDEN',
140
+ details: { ability: 'singletons.site-settings.read' },
141
+ }
142
+ mocks.getSingletonHistory.mockRejectedValue(forbidden)
143
+
144
+ await expect(loader()({ params: { singleton: 'site-settings' }, deps: {} })).rejects.toBe(
145
+ forbidden
146
+ )
147
+ })
148
+ })
@@ -0,0 +1,116 @@
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 { createFileRoute, notFound } from '@tanstack/react-router'
10
+
11
+ import { getSingletonAdminConfig, getWorkflowStatuses } from '@byline/core'
12
+ import { useTranslation } from '@byline/i18n/react'
13
+ import { z } from 'zod'
14
+
15
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
16
+ import { SingletonHistoryView } from '../admin-shell/singletons/history.js'
17
+ import { getSingleton, getSingletonHistory } from '../server-fns/singletons/index.js'
18
+ import { getAdminRoutePath } from './admin-path.js'
19
+ import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
20
+ import { getSingletonDefinition } from './get-singleton-definition.js'
21
+ import type { VersionHistoryData } from '../admin-shell/collections/version-history.js'
22
+
23
+ const searchSchema = z.object({
24
+ page: z.coerce.number().min(1).optional(),
25
+ page_size: z.coerce.number().max(100).optional(),
26
+ order: z.string().optional(),
27
+ desc: z.coerce.boolean().optional(),
28
+ locale: z.string().optional(),
29
+ })
30
+
31
+ export function createSingletonHistoryRoute(path: string) {
32
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
33
+ const Route: any = createFileRoute(path as never)({
34
+ validateSearch: searchSchema,
35
+ loaderDeps: ({ search }: { search: z.infer<typeof searchSchema> }) => ({
36
+ page: search.page,
37
+ page_size: search.page_size,
38
+ order: search.order,
39
+ desc: search.desc,
40
+ locale: search.locale,
41
+ }),
42
+ loader: async ({
43
+ params,
44
+ deps,
45
+ }: {
46
+ params: { singleton: string }
47
+ deps: z.infer<typeof searchSchema>
48
+ }) => {
49
+ if (getSingletonDefinition(params.singleton) == null) throw notFound()
50
+
51
+ const [history, currentDocument] = await Promise.all([
52
+ getSingletonHistory({
53
+ data: {
54
+ singleton: params.singleton,
55
+ params: {
56
+ page: deps.page,
57
+ page_size: deps.page_size,
58
+ order: deps.order,
59
+ desc: deps.desc,
60
+ locale: deps.locale,
61
+ },
62
+ },
63
+ }),
64
+ // Use the same locale shape as the version stream so DiffModal compares
65
+ // like with like. An empty slot returns null while history returns its
66
+ // requested empty FindResult envelope.
67
+ getSingleton({
68
+ data: { singleton: params.singleton, locale: deps.locale ?? 'all' },
69
+ }),
70
+ ])
71
+
72
+ return { history, currentDocument }
73
+ },
74
+ staleTime: 0,
75
+ gcTime: 0,
76
+ shouldReload: true,
77
+ component: function SingletonHistoryRouteComponent() {
78
+ const { history, currentDocument } = Route.useLoaderData()
79
+ const { singleton } = Route.useParams() as { singleton: string }
80
+ const definition = getSingletonDefinition(singleton)
81
+ if (definition == null) throw notFound()
82
+
83
+ const { t } = useTranslation('byline-admin')
84
+ const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig()
85
+
86
+ return (
87
+ <>
88
+ <BreadcrumbsClient
89
+ breadcrumbs={[
90
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
91
+ {
92
+ label: definition.label,
93
+ href: getAdminRoutePath('singletons', singleton),
94
+ },
95
+ {
96
+ label: t('collections.breadcrumbs.history'),
97
+ href: getAdminRoutePath('singletons', singleton, 'history'),
98
+ },
99
+ ]}
100
+ />
101
+ <SingletonHistoryView
102
+ singletonDefinition={definition}
103
+ adminConfig={getSingletonAdminConfig(singleton) ?? undefined}
104
+ data={history as VersionHistoryData}
105
+ currentDocument={currentDocument as Record<string, unknown> | null}
106
+ contentLocales={contentLocales}
107
+ defaultContentLocale={defaultContentLocale}
108
+ workflowStatuses={getWorkflowStatuses(definition)}
109
+ />
110
+ </>
111
+ )
112
+ },
113
+ })
114
+
115
+ return Route
116
+ }
@@ -0,0 +1,172 @@
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 { beforeEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ const mocks = vi.hoisted(() => ({
12
+ definition: {
13
+ singleton: true,
14
+ path: 'site-settings',
15
+ label: 'Site settings',
16
+ fields: [{ name: 'title', type: 'text', defaultValue: 'Default title' }],
17
+ },
18
+ read: vi.fn(),
19
+ buildInitialData: vi.fn(),
20
+ getPublishedVersion: vi.fn(),
21
+ getRequestContext: vi.fn(),
22
+ getScheduledPublish: vi.fn(),
23
+ getCollectionRecord: vi.fn(),
24
+ scheduledPublicationEnabled: true,
25
+ }))
26
+
27
+ vi.mock('@tanstack/react-start', () => ({
28
+ createServerFn: () => {
29
+ let validate = (input: unknown) => input
30
+ const chain = {
31
+ validator(validator: (input: unknown) => unknown) {
32
+ validate = validator
33
+ return chain
34
+ },
35
+ handler(handler: (options: { data: any }) => Promise<unknown>) {
36
+ return async (options: { data: unknown }) => handler({ data: validate(options.data) })
37
+ },
38
+ }
39
+ return chain
40
+ },
41
+ }))
42
+
43
+ vi.mock('@tanstack/react-router', () => ({
44
+ createFileRoute: () => (options: Record<string, unknown>) => ({ options }),
45
+ notFound: () => new Error('not found'),
46
+ }))
47
+
48
+ vi.mock('@byline/client/server', () => ({
49
+ getAdminBylineClient: () => ({
50
+ singleton: () => ({ getScheduledPublish: mocks.getScheduledPublish }),
51
+ }),
52
+ getAdminRequestContext: mocks.getRequestContext,
53
+ }))
54
+
55
+ vi.mock('@byline/core', () => ({
56
+ buildInitialDataFromFields: mocks.buildInitialData,
57
+ documentAbilityKey: (_definition: unknown, verb: string) => `singletons.site-settings.${verb}`,
58
+ getCollectionDefinition: (path: string) =>
59
+ path === mocks.definition.path ? mocks.definition : null,
60
+ isSingleton: (definition: { singleton?: boolean }) => definition.singleton === true,
61
+ getServerConfig: () => ({
62
+ i18n: { content: { defaultLocale: 'en' } },
63
+ scheduledPublication: { enabled: mocks.scheduledPublicationEnabled },
64
+ db: { queries: { documents: { getPublishedVersion: mocks.getPublishedVersion } } },
65
+ }),
66
+ getSingletonAdminConfig: () => null,
67
+ }))
68
+
69
+ vi.mock('../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js', () => ({
70
+ BreadcrumbsClient: () => null,
71
+ }))
72
+ vi.mock('../admin-shell/singletons/view.js', () => ({ SingletonView: () => null }))
73
+ vi.mock('../integrations/byline-core.js', () => ({
74
+ bylineCore: () => ({ getCollectionRecord: mocks.getCollectionRecord }),
75
+ }))
76
+ vi.mock('../server-fns/singleton-document-read.js', () => ({
77
+ readSingletonDocument: mocks.read,
78
+ }))
79
+ vi.mock('../server-fns/serialise.js', () => ({ serialise: (value: unknown) => value }))
80
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
81
+ vi.mock('./admin-path.js', () => ({ getAdminRoutePath: () => '/admin' }))
82
+ vi.mock('./get-content-locale-route-config.js', () => ({
83
+ getContentLocaleRouteConfig: () => ({ contentLocales: [], defaultContentLocale: 'en' }),
84
+ }))
85
+
86
+ import { getSingletonRouteDocument } from './create-singleton-route.js'
87
+
88
+ type ServerFunction = (options: { data: { singleton: string; locale?: string } }) => Promise<any>
89
+
90
+ const load = (locale?: string) =>
91
+ (getSingletonRouteDocument as ServerFunction)({
92
+ data: { singleton: 'site-settings', locale },
93
+ })
94
+
95
+ describe('singleton editor route loader', () => {
96
+ beforeEach(() => {
97
+ vi.clearAllMocks()
98
+ mocks.scheduledPublicationEnabled = true
99
+ mocks.buildInitialData.mockResolvedValue({ title: 'Default title' })
100
+ mocks.getCollectionRecord.mockReturnValue({ collectionId: 'collection-settings' })
101
+ mocks.getRequestContext.mockResolvedValue({
102
+ actor: {
103
+ hasAbility: (ability: string) =>
104
+ ability === 'singletons.site-settings.changeStatus' ||
105
+ ability === 'singletons.site-settings.publish',
106
+ },
107
+ })
108
+ })
109
+
110
+ it('resolves schema defaults without inventing a document for an empty slot', async () => {
111
+ mocks.read.mockResolvedValue(null)
112
+
113
+ await expect(load('fr')).resolves.toEqual({
114
+ document: null,
115
+ initialData: { title: 'Default title' },
116
+ })
117
+ expect(mocks.read).toHaveBeenCalledWith({
118
+ singleton: 'site-settings',
119
+ locale: 'fr',
120
+ populateRelations: true,
121
+ })
122
+ expect(mocks.buildInitialData).toHaveBeenCalledWith(mocks.definition.fields, { locale: 'fr' })
123
+ expect(mocks.getCollectionRecord).not.toHaveBeenCalled()
124
+ expect(mocks.getRequestContext).not.toHaveBeenCalled()
125
+ })
126
+
127
+ it('attaches publication and schedule metadata to a mapped document server-side', async () => {
128
+ mocks.read.mockResolvedValue({
129
+ id: 'document-settings',
130
+ versionId: 'version-current',
131
+ status: 'draft',
132
+ fields: { title: 'Loaded' },
133
+ })
134
+ mocks.getPublishedVersion.mockResolvedValue({
135
+ document_id: 'document-settings',
136
+ document_version_id: 'version-published',
137
+ status: 'published',
138
+ created_at: '2026-08-25T00:00:00.000Z',
139
+ updated_at: '2026-08-25T01:00:00.000Z',
140
+ })
141
+ mocks.getScheduledPublish.mockResolvedValue({
142
+ state: 'armed',
143
+ publishAt: '2026-09-01T00:00:00.000Z',
144
+ targetVersionId: 'version-current',
145
+ })
146
+
147
+ const result = await load()
148
+
149
+ expect(result.initialData).toBeUndefined()
150
+ expect(result.document).toMatchObject({
151
+ id: 'document-settings',
152
+ _publishedVersion: {
153
+ id: 'document-settings',
154
+ versionId: 'version-published',
155
+ status: 'published',
156
+ },
157
+ _scheduledPublicationEnabled: true,
158
+ _canSchedulePublication: true,
159
+ _scheduledPublish: {
160
+ state: 'armed',
161
+ targetVersionId: 'version-current',
162
+ },
163
+ scheduledPublishAt: '2026-09-01T00:00:00.000Z',
164
+ scheduledPublishVersionId: 'version-current',
165
+ })
166
+ expect(mocks.getPublishedVersion).toHaveBeenCalledWith({
167
+ collection_id: 'collection-settings',
168
+ document_id: 'document-settings',
169
+ })
170
+ expect(mocks.getScheduledPublish).toHaveBeenCalledTimes(1)
171
+ })
172
+ })
@@ -0,0 +1,176 @@
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 { createFileRoute, notFound } from '@tanstack/react-router'
10
+ import { createServerFn } from '@tanstack/react-start'
11
+
12
+ import type { DocumentPublishScheduleInfo } from '@byline/client'
13
+ import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
14
+ import {
15
+ buildInitialDataFromFields,
16
+ documentAbilityKey,
17
+ getServerConfig,
18
+ getSingletonAdminConfig,
19
+ } from '@byline/core'
20
+ import { useTranslation } from '@byline/i18n/react'
21
+ import { z } from 'zod'
22
+
23
+ import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
24
+ import { SingletonView } from '../admin-shell/singletons/view.js'
25
+ import { bylineCore } from '../integrations/byline-core.js'
26
+ import { serialise } from '../server-fns/serialise.js'
27
+ import { readSingletonDocument } from '../server-fns/singleton-document-read.js'
28
+ import { getAdminRoutePath } from './admin-path.js'
29
+ import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
30
+ import { getSingletonDefinition } from './get-singleton-definition.js'
31
+
32
+ const searchSchema = z.object({ locale: z.string().optional() })
33
+
34
+ /**
35
+ * Load the mapped singleton document and attach the admin-only lifecycle
36
+ * metadata consumed by the editor. Keeping this composition in the route
37
+ * layer leaves ordinary singleton reads free of publication timing and actor
38
+ * capability details.
39
+ */
40
+ export const getSingletonRouteDocument = createServerFn({ method: 'GET' })
41
+ .validator((input: { singleton: string; locale?: string }) => input)
42
+ .handler(async ({ data }) => {
43
+ const definition = getSingletonDefinition(data.singleton)
44
+ if (definition == null) return null
45
+
46
+ const document = await readSingletonDocument({
47
+ singleton: data.singleton,
48
+ locale: data.locale,
49
+ populateRelations: true,
50
+ })
51
+ if (document == null) {
52
+ return {
53
+ document: null,
54
+ initialData: serialise(
55
+ await buildInitialDataFromFields(definition.fields, {
56
+ locale: data.locale ?? getServerConfig().i18n.content.defaultLocale,
57
+ })
58
+ ),
59
+ }
60
+ }
61
+
62
+ const config = getServerConfig()
63
+ const record = bylineCore().getCollectionRecord(data.singleton)
64
+
65
+ // A current draft may coexist with an older published version. That fact
66
+ // is editorial metadata, so it is intentionally attached only here rather
67
+ // than to every singleton read.
68
+ let publishedVersion: Record<string, any> | null = null
69
+ if ((document as Record<string, unknown>).status !== 'published') {
70
+ const published = await config.db.queries.documents.getPublishedVersion({
71
+ collection_id: record.collectionId,
72
+ document_id: String((document as Record<string, unknown>).id),
73
+ })
74
+ publishedVersion = published
75
+ ? serialise({
76
+ id: published.document_id,
77
+ versionId: published.document_version_id,
78
+ status: published.status,
79
+ createdAt: published.created_at,
80
+ updatedAt: published.updated_at,
81
+ })
82
+ : null
83
+ }
84
+
85
+ // Scheduling metadata is revealed only when the feature is enabled and
86
+ // the authenticated actor holds both abilities needed to arm a publish.
87
+ const scheduledPublicationEnabled = config.scheduledPublication?.enabled === true
88
+ let canSchedulePublication = false
89
+ let scheduledPublish: DocumentPublishScheduleInfo | null = null
90
+ if (scheduledPublicationEnabled) {
91
+ const actor = (await getAdminRequestContext()).actor
92
+ canSchedulePublication =
93
+ actor?.hasAbility(documentAbilityKey(definition, 'changeStatus')) === true &&
94
+ actor.hasAbility(documentAbilityKey(definition, 'publish'))
95
+ if (canSchedulePublication) {
96
+ scheduledPublish = await getAdminBylineClient()
97
+ .singleton(data.singleton)
98
+ .getScheduledPublish()
99
+ }
100
+ }
101
+ const serializedSchedule = scheduledPublish == null ? null : serialise(scheduledPublish)
102
+
103
+ return {
104
+ document: {
105
+ ...(document as Record<string, unknown>),
106
+ _publishedVersion: publishedVersion,
107
+ _scheduledPublicationEnabled: scheduledPublicationEnabled,
108
+ _canSchedulePublication: canSchedulePublication,
109
+ _scheduledPublish: serializedSchedule,
110
+ ...(serializedSchedule == null
111
+ ? {}
112
+ : {
113
+ scheduledPublishAt: serializedSchedule.publishAt,
114
+ scheduledPublishVersionId: serializedSchedule.targetVersionId,
115
+ }),
116
+ },
117
+ initialData: undefined,
118
+ }
119
+ })
120
+
121
+ export function createSingletonRoute(path: string) {
122
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic path bypasses route-tree typing
123
+ const Route: any = createFileRoute(path as never)({
124
+ validateSearch: searchSchema,
125
+ loaderDeps: ({ search }: { search: z.infer<typeof searchSchema> }) => ({
126
+ locale: search.locale,
127
+ }),
128
+ loader: async ({
129
+ params,
130
+ deps,
131
+ }: {
132
+ params: { singleton: string }
133
+ deps: { locale?: string }
134
+ }) => {
135
+ if (getSingletonDefinition(params.singleton) == null) throw notFound()
136
+ return getSingletonRouteDocument({
137
+ data: { singleton: params.singleton, locale: deps.locale },
138
+ })
139
+ },
140
+ staleTime: 0,
141
+ gcTime: 0,
142
+ shouldReload: true,
143
+ component: function SingletonRouteComponent() {
144
+ const routeState = Route.useLoaderData()
145
+ const { singleton } = Route.useParams() as { singleton: string }
146
+ const { locale } = Route.useSearch() as z.infer<typeof searchSchema>
147
+ const definition = getSingletonDefinition(singleton)
148
+ if (definition == null) throw notFound()
149
+
150
+ const { t } = useTranslation('byline-admin')
151
+ const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig()
152
+
153
+ return (
154
+ <>
155
+ <BreadcrumbsClient
156
+ breadcrumbs={[
157
+ { label: t('chrome.menu.dashboard'), href: getAdminRoutePath() },
158
+ { label: definition.label, href: getAdminRoutePath('singletons', singleton) },
159
+ ]}
160
+ />
161
+ <SingletonView
162
+ singletonDefinition={definition}
163
+ adminConfig={getSingletonAdminConfig(singleton) ?? undefined}
164
+ document={routeState.document}
165
+ initialData={routeState.initialData}
166
+ locale={locale}
167
+ contentLocales={contentLocales}
168
+ defaultContentLocale={defaultContentLocale}
169
+ />
170
+ </>
171
+ )
172
+ },
173
+ })
174
+
175
+ return Route
176
+ }
@@ -0,0 +1,16 @@
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 { MultiCollectionDefinition } from '@byline/core'
10
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
11
+
12
+ /** Resolve only the resource kind supported by the collection route family. */
13
+ export function getMultiCollectionDefinition(path: string): MultiCollectionDefinition | null {
14
+ const definition = getCollectionDefinition(path)
15
+ return definition != null && !isSingleton(definition) ? definition : null
16
+ }
@@ -0,0 +1,16 @@
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 { SingletonDefinition } from '@byline/core'
10
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
11
+
12
+ /** Resolve only the resource kind supported by the singleton route family. */
13
+ export function getSingletonDefinition(path: string): SingletonDefinition | null {
14
+ const definition = getCollectionDefinition(path)
15
+ return definition != null && isSingleton(definition) ? definition : null
16
+ }