@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,351 @@
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 { useRouter } from '@tanstack/react-router'
12
+
13
+ import { FormRenderer } from '@byline/admin/react'
14
+ import type { SingletonAdminConfig, SingletonDefinition } from '@byline/core'
15
+ import {
16
+ ErrorCodes,
17
+ getDefaultStatus,
18
+ getWorkflow,
19
+ getWorkflowStatuses,
20
+ validateStatusTransition,
21
+ } from '@byline/core'
22
+ import { useTranslation } from '@byline/i18n/react'
23
+ import { Container, Section, useToastManager } from '@byline/ui/react'
24
+
25
+ import { getAdminRoutePath } from '../../routes/admin-path.js'
26
+ import {
27
+ cancelSingletonScheduledPublish,
28
+ changeSingletonStatus,
29
+ confirmSingletonScheduledPublish,
30
+ copySingletonToLocale,
31
+ scheduleSingletonPublish,
32
+ unpublishSingleton,
33
+ updateSingleton,
34
+ } from '../../server-fns/singletons/index.js'
35
+ import { useNavigate } from '../chrome/loose-router.js'
36
+ import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
37
+ import { SingletonViewMenu } from './view-menu.js'
38
+ import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
39
+ import type { ContentLocaleOption } from '../collections/view-menu.js'
40
+
41
+ type SingletonDocument = Record<string, any> & {
42
+ id: string
43
+ status: string
44
+ fields: Record<string, any>
45
+ }
46
+
47
+ function errorCode(error: unknown): string | null {
48
+ return typeof (error as { code?: unknown })?.code === 'string'
49
+ ? (error as { code: string }).code
50
+ : null
51
+ }
52
+
53
+ export function SingletonView({
54
+ singletonDefinition,
55
+ adminConfig,
56
+ document,
57
+ initialData,
58
+ locale,
59
+ contentLocales,
60
+ defaultContentLocale,
61
+ }: {
62
+ singletonDefinition: SingletonDefinition
63
+ adminConfig?: SingletonAdminConfig
64
+ document: SingletonDocument | null
65
+ /** Schema defaults resolved by the server-side route loader for an empty slot. */
66
+ initialData?: Record<string, any>
67
+ locale?: string
68
+ contentLocales: ReadonlyArray<ContentLocaleOption>
69
+ defaultContentLocale: string
70
+ }) {
71
+ const router = useRouter()
72
+ const navigate = useNavigate()
73
+ const toastManager = useToastManager()
74
+ const { t } = useTranslation('byline-admin')
75
+ const { path, label, fields } = singletonDefinition
76
+
77
+ const workflowStatuses = getWorkflowStatuses(singletonDefinition)
78
+ const currentStatus = document?.status ?? getDefaultStatus(singletonDefinition)
79
+ const currentIndex = workflowStatuses.findIndex((status) => status.name === currentStatus)
80
+ const nextStatus =
81
+ currentIndex !== -1 && currentIndex < workflowStatuses.length - 1
82
+ ? workflowStatuses[currentIndex + 1]
83
+ : undefined
84
+ const publishedVersion = document?._publishedVersion ?? null
85
+ const restoreWarnings = document?._restoreWarnings as string[] | undefined
86
+ const scheduledPublicationEnabled = document?._scheduledPublicationEnabled === true
87
+ const canSchedulePublication = document?._canSchedulePublication === true
88
+ const scheduledPublication = (document?._scheduledPublish ??
89
+ null) as SerializedDocumentPublishSchedule | null
90
+ const publishTransitionValid =
91
+ document != null &&
92
+ currentStatus !== 'published' &&
93
+ workflowStatuses.length > 1 &&
94
+ validateStatusTransition(getWorkflow(singletonDefinition), currentStatus, 'published').valid
95
+ const canArmSchedule =
96
+ scheduledPublicationEnabled && canSchedulePublication && publishTransitionValid
97
+
98
+ const reload = async () => {
99
+ // Invalidation is load-bearing after the first save: the refreshed loader
100
+ // supplies the mapped document id/version, flips FormRenderer to edit mode,
101
+ // and unlocks fields that require a saved document.
102
+ await router.invalidate()
103
+ }
104
+
105
+ const toast = (title: string, description: string, intent: 'success' | 'warning' | 'danger') => {
106
+ toastManager.add({
107
+ title,
108
+ description,
109
+ data: { intent, iconType: intent, icon: true, close: true },
110
+ })
111
+ }
112
+
113
+ const notifyScheduleSuspended = () => {
114
+ if (scheduledPublication?.state !== 'armed') return
115
+ toast(
116
+ t('scheduledPublication.toast.suspendedTitle'),
117
+ t('scheduledPublication.toast.suspendedDescription'),
118
+ 'warning'
119
+ )
120
+ }
121
+
122
+ const handleSubmit = async ({ data }: { data: Record<string, any> }) => {
123
+ try {
124
+ await updateSingleton({
125
+ data: {
126
+ singleton: path,
127
+ data,
128
+ locale: locale ?? defaultContentLocale,
129
+ expectedVersionId: document?.versionId,
130
+ },
131
+ })
132
+ if (document != null) notifyScheduleSuspended()
133
+ toast(
134
+ t('singletons.edit.updateTitle', { label }),
135
+ t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
136
+ 'success'
137
+ )
138
+ await reload()
139
+ } catch (err) {
140
+ const code = errorCode(err)
141
+ const description =
142
+ code === ErrorCodes.CONFLICT
143
+ ? t('singletons.edit.conflictDescription')
144
+ : code === ErrorCodes.NOT_FOUND
145
+ ? t('singletons.edit.notConfiguredDescription')
146
+ : t('singletons.edit.updateFailedDescription', { label: label.toLowerCase() })
147
+ toast(t('singletons.edit.updateTitle', { label }), description, 'danger')
148
+
149
+ // FormRenderer commits its clean baseline only when this promise
150
+ // resolves. Rethrow so a failed singleton save leaves the editor dirty
151
+ // and its navigation guard active.
152
+ throw err
153
+ }
154
+ }
155
+
156
+ const handleStatusChange = async (status: string) => {
157
+ try {
158
+ await changeSingletonStatus({ data: { singleton: path, status } })
159
+ toast(
160
+ t('collections.edit.statusUpdateTitle', { label }),
161
+ t('collections.edit.statusChangedDescription', { status }),
162
+ 'success'
163
+ )
164
+ await reload()
165
+ } catch (err) {
166
+ toast(
167
+ t('collections.edit.statusUpdateTitle', { label }),
168
+ t('collections.edit.statusChangeFailedDescription', { message: (err as Error).message }),
169
+ 'danger'
170
+ )
171
+ throw err
172
+ }
173
+ }
174
+
175
+ const handleUnpublish = async () => {
176
+ try {
177
+ await unpublishSingleton({ data: { singleton: path } })
178
+ toast(
179
+ t('collections.edit.unpublishTitle', { label }),
180
+ t('collections.edit.unpublishedDescription'),
181
+ 'success'
182
+ )
183
+ await reload()
184
+ } catch (err) {
185
+ toast(
186
+ t('collections.edit.unpublishTitle', { label }),
187
+ t('collections.edit.unpublishFailedDescription', { message: (err as Error).message }),
188
+ 'danger'
189
+ )
190
+ throw err
191
+ }
192
+ }
193
+
194
+ const handleCopyToLocale = async ({
195
+ targetLocale,
196
+ overwrite,
197
+ }: {
198
+ targetLocale: string
199
+ overwrite: boolean
200
+ }) => {
201
+ try {
202
+ await copySingletonToLocale({
203
+ data: {
204
+ singleton: path,
205
+ sourceLocale: locale ?? defaultContentLocale,
206
+ targetLocale,
207
+ overwrite,
208
+ },
209
+ })
210
+ const sourceLocale = locale ?? defaultContentLocale
211
+ const sourceLabel =
212
+ contentLocales.find((entry) => entry.code === sourceLocale)?.label ?? sourceLocale
213
+ const targetLabel =
214
+ contentLocales.find((entry) => entry.code === targetLocale)?.label ?? targetLocale
215
+ toast(
216
+ t('collections.edit.copyToLocaleTitle', { label }),
217
+ t('collections.edit.copiedSuccessMessage', {
218
+ source: sourceLabel,
219
+ target: targetLabel,
220
+ }),
221
+ 'success'
222
+ )
223
+ notifyScheduleSuspended()
224
+ navigate({
225
+ to: getAdminRoutePath('singletons', '$singleton'),
226
+ params: { singleton: path },
227
+ search: { locale: targetLocale },
228
+ })
229
+ } catch (err) {
230
+ toast(
231
+ t('collections.edit.copyToLocaleTitle', { label }),
232
+ t('collections.edit.copyFailedDescription', { message: (err as Error).message }),
233
+ 'danger'
234
+ )
235
+ throw err
236
+ }
237
+ }
238
+
239
+ const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
240
+ try {
241
+ await scheduleSingletonPublish({
242
+ data: { singleton: path, publishAt, expectedVersionId: String(document?.versionId) },
243
+ })
244
+ toast(
245
+ t('scheduledPublication.toast.scheduledTitle'),
246
+ t('scheduledPublication.toast.scheduledDescription'),
247
+ 'success'
248
+ )
249
+ await reload()
250
+ } catch (err) {
251
+ toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
252
+ throw err
253
+ }
254
+ }
255
+
256
+ const handleConfirmScheduledPublication = async () => {
257
+ try {
258
+ await confirmSingletonScheduledPublish({
259
+ data: { singleton: path, expectedVersionId: String(document?.versionId) },
260
+ })
261
+ toast(
262
+ t('scheduledPublication.toast.confirmedTitle'),
263
+ t('scheduledPublication.toast.confirmedDescription'),
264
+ 'success'
265
+ )
266
+ await reload()
267
+ } catch (err) {
268
+ toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
269
+ throw err
270
+ }
271
+ }
272
+
273
+ const handleCancelScheduledPublication = async () => {
274
+ try {
275
+ await cancelSingletonScheduledPublish({ data: { singleton: path } })
276
+ toast(
277
+ t('scheduledPublication.toast.cancelledTitle'),
278
+ t('scheduledPublication.toast.cancelledDescription'),
279
+ 'success'
280
+ )
281
+ await reload()
282
+ } catch (err) {
283
+ toast(t('scheduledPublication.toast.failedTitle'), (err as Error).message, 'danger')
284
+ throw err
285
+ }
286
+ }
287
+
288
+ return (
289
+ <Section>
290
+ <Container>
291
+ <FormRenderer
292
+ mode={document == null ? 'create' : 'edit'}
293
+ fields={fields}
294
+ onSubmit={handleSubmit}
295
+ initialData={document ?? (initialData ? { fields: initialData } : undefined)}
296
+ adminConfig={adminConfig}
297
+ heading={label}
298
+ showPath={false}
299
+ initialLocale={locale}
300
+ defaultLocale={defaultContentLocale}
301
+ onLocaleChange={
302
+ document == null
303
+ ? undefined
304
+ : (nextLocale) =>
305
+ navigate({
306
+ to: getAdminRoutePath('singletons', '$singleton'),
307
+ params: { singleton: path },
308
+ search: { locale: nextLocale },
309
+ })
310
+ }
311
+ useNavigationGuard={useTanStackNavigationGuard}
312
+ headerSlot={
313
+ document == null ? undefined : (
314
+ <SingletonViewMenu
315
+ singleton={path}
316
+ activeView="edit"
317
+ locale={locale}
318
+ contentLocales={contentLocales}
319
+ defaultContentLocale={defaultContentLocale}
320
+ adminConfig={adminConfig}
321
+ doc={document}
322
+ />
323
+ )
324
+ }
325
+ onStatusChange={document == null ? undefined : handleStatusChange}
326
+ onUnpublish={document != null && publishedVersion ? handleUnpublish : undefined}
327
+ scheduledPublication={document == null ? null : scheduledPublication}
328
+ onSchedulePublication={canArmSchedule ? handleSchedulePublication : undefined}
329
+ onConfirmScheduledPublication={
330
+ canArmSchedule && scheduledPublication?.state === 'needs_reconfirm'
331
+ ? handleConfirmScheduledPublication
332
+ : undefined
333
+ }
334
+ onCancelScheduledPublication={
335
+ document != null && scheduledPublication != null
336
+ ? handleCancelScheduledPublication
337
+ : undefined
338
+ }
339
+ onCopyToLocale={document == null ? undefined : handleCopyToLocale}
340
+ contentLocales={document == null ? undefined : contentLocales}
341
+ publishedVersion={document == null ? null : publishedVersion}
342
+ restoreWarnings={restoreWarnings}
343
+ nextStatus={document == null ? undefined : nextStatus}
344
+ workflowStatuses={document == null ? undefined : workflowStatuses}
345
+ onCancel={() => navigate({ to: getAdminRoutePath() })}
346
+ collectionPath={path}
347
+ />
348
+ </Container>
349
+ </Section>
350
+ )
351
+ }
@@ -16,12 +16,16 @@
16
16
  // src/server.ts (TanStack Start server entry point) before any
17
17
  // requests are handled. No need to import it here.
18
18
 
19
- import type { CollectionDefinition, CollectionRecord } from '@byline/core'
20
- import { getCollectionDefinition } from '@byline/core'
19
+ import type {
20
+ CollectionDefinition,
21
+ CollectionRecord,
22
+ MultiCollectionDefinition,
23
+ } from '@byline/core'
24
+ import { getCollectionDefinition, isSingleton } from '@byline/core'
21
25
 
22
26
  import { bylineCore } from './byline-core.js'
23
27
 
24
- export interface EnsuredCollection {
28
+ export interface EnsuredDocumentResource {
25
29
  definition: CollectionDefinition
26
30
  collection: {
27
31
  id: string
@@ -31,21 +35,28 @@ export interface EnsuredCollection {
31
35
  }
32
36
  }
33
37
 
38
+ export interface EnsuredCollection extends EnsuredDocumentResource {
39
+ definition: MultiCollectionDefinition
40
+ }
41
+
34
42
  /**
35
- * Resolve a collection for an admin API request.
43
+ * Resolve either registered document-resource kind against the reconciled
44
+ * collection record.
36
45
  *
37
46
  * Collections are reconciled with the database once at startup by
38
47
  * `initBylineCore()` (see `packages/core/src/services/collection-bootstrap.ts`).
39
48
  * This helper is a per-request cache lookup against the resulting in-memory
40
49
  * registry — no DB I/O, no hash work.
41
50
  *
42
- * Returns `null` when the path is not registered in the client/server config.
51
+ * This broader seam is reserved for operations such as field upload whose core
52
+ * service is explicitly kind-aware; collection route families continue to use
53
+ * `ensureCollection` below.
43
54
  */
44
- export async function ensureCollection(path: string): Promise<EnsuredCollection | null> {
55
+ export async function ensureDocumentResource(
56
+ path: string
57
+ ): Promise<EnsuredDocumentResource | null> {
45
58
  const definition = getCollectionDefinition(path)
46
- if (definition == null) {
47
- return null
48
- }
59
+ if (definition == null) return null
49
60
 
50
61
  let record: CollectionRecord
51
62
  try {
@@ -64,3 +75,17 @@ export async function ensureCollection(path: string): Promise<EnsuredCollection
64
75
  },
65
76
  }
66
77
  }
78
+
79
+ /**
80
+ * Narrow the shared document-resource resolution to a multi-document
81
+ * collection for a collection-scoped admin API request.
82
+ *
83
+ * Returns `null` when the path is unregistered or belongs to a singleton, so
84
+ * collection route families cannot accidentally admit the other resource kind.
85
+ */
86
+ export async function ensureCollection(path: string): Promise<EnsuredCollection | null> {
87
+ const resource = await ensureDocumentResource(path)
88
+ return resource != null && !isSingleton(resource.definition)
89
+ ? { ...resource, definition: resource.definition }
90
+ : null
91
+ }
@@ -0,0 +1,70 @@
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
+ getCollectionStats: vi.fn(),
13
+ collections: [
14
+ { path: 'articles', labels: { singular: 'Article', plural: 'Articles' }, showStats: true },
15
+ {
16
+ singleton: true,
17
+ path: 'site-settings',
18
+ label: 'Site settings',
19
+ // Deliberately collection-like: kind must win even for malformed input.
20
+ showStats: true,
21
+ },
22
+ ],
23
+ }))
24
+
25
+ vi.mock('@tanstack/react-router', () => ({
26
+ createFileRoute: () => (options: Record<string, unknown>) => ({ options }),
27
+ }))
28
+
29
+ vi.mock('@byline/core', () => ({
30
+ filterReadableCollections: (collections: unknown[]) => collections,
31
+ getAdminConfig: () => ({ collections: mocks.collections }),
32
+ isSingleton: (definition: { singleton?: boolean }) => definition.singleton === true,
33
+ }))
34
+
35
+ vi.mock('../server-fns/collections/index.js', () => ({
36
+ getCollectionStats: mocks.getCollectionStats,
37
+ }))
38
+ vi.mock('../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js', () => ({
39
+ BreadcrumbsClient: () => null,
40
+ }))
41
+ vi.mock('../admin-shell/chrome/dashboard.js', () => ({ AdminDashboard: () => null }))
42
+ vi.mock('@byline/i18n/react', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
43
+ vi.mock('./admin-path.js', () => ({ getAdminRoutePath: () => '/admin' }))
44
+
45
+ import { createAdminDashboardRoute } from './create-admin-dashboard-route.js'
46
+
47
+ type DashboardLoader = (input: {
48
+ context: { user: { is_super_admin: boolean; abilities: string[] } }
49
+ }) => Promise<{ statsMap: Record<string, Array<{ status: string; count: number }>> }>
50
+
51
+ describe('admin dashboard route loader', () => {
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+ mocks.getCollectionStats.mockResolvedValue([{ status: 'published', count: 4 }])
55
+ })
56
+
57
+ it('never requests collection statistics for a singleton resource', async () => {
58
+ const route = createAdminDashboardRoute('/admin/') as {
59
+ options: { loader: DashboardLoader }
60
+ }
61
+
62
+ await expect(
63
+ route.options.loader({ context: { user: { is_super_admin: true, abilities: [] } } })
64
+ ).resolves.toEqual({ statsMap: { articles: [{ status: 'published', count: 4 }] } })
65
+
66
+ expect(mocks.getCollectionStats).toHaveBeenCalledOnce()
67
+ expect(mocks.getCollectionStats).toHaveBeenCalledWith('articles')
68
+ expect(mocks.getCollectionStats).not.toHaveBeenCalledWith('site-settings')
69
+ })
70
+ })
@@ -8,7 +8,12 @@
8
8
 
9
9
  import { createFileRoute } from '@tanstack/react-router'
10
10
 
11
- import { filterReadableCollections, getAdminConfig } from '@byline/core'
11
+ import {
12
+ filterReadableCollections,
13
+ getAdminConfig,
14
+ isSingleton,
15
+ type MultiCollectionDefinition,
16
+ } from '@byline/core'
12
17
  import { useTranslation } from '@byline/i18n/react'
13
18
 
14
19
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
@@ -40,7 +45,10 @@ export function createAdminDashboardRoute(path: string) {
40
45
 
41
46
  await Promise.all(
42
47
  visible
43
- .filter((c) => c.showStats === true)
48
+ .filter(
49
+ (resource): resource is MultiCollectionDefinition =>
50
+ !isSingleton(resource) && resource.showStats === true
51
+ )
44
52
  .map(async (c) => {
45
53
  try {
46
54
  statsMap[c.path] = await getCollectionStats(c.path)
@@ -8,8 +8,7 @@
8
8
 
9
9
  import { createFileRoute, notFound } from '@tanstack/react-router'
10
10
 
11
- import type { CollectionDefinition } from '@byline/core'
12
- import { getCollectionDefinition } from '@byline/core'
11
+ import type { MultiCollectionDefinition } from '@byline/core'
13
12
  import { useTranslation } from '@byline/i18n/react'
14
13
  import { z } from 'zod'
15
14
 
@@ -18,6 +17,7 @@ import { ApiView } from '../admin-shell/collections/api.js'
18
17
  import { getCollectionDocument } from '../server-fns/collections/index.js'
19
18
  import { getAdminRoutePath } from './admin-path.js'
20
19
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
20
+ import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
21
21
 
22
22
  const searchSchema = z.object({
23
23
  locale: z.string().optional(),
@@ -44,7 +44,7 @@ export function createCollectionApiRoute(path: string) {
44
44
  params: { collection: string; id: string }
45
45
  deps: { locale?: string; depth?: number }
46
46
  }) => {
47
- const collectionDef = getCollectionDefinition(params.collection)
47
+ const collectionDef = getMultiCollectionDefinition(params.collection)
48
48
  if (!collectionDef) {
49
49
  throw notFound()
50
50
  }
@@ -74,7 +74,7 @@ export function createCollectionApiRoute(path: string) {
74
74
  const data = Route.useLoaderData()
75
75
  const { collection, id } = Route.useParams() as { collection: string; id: string }
76
76
  const { locale, depth } = Route.useSearch() as z.infer<typeof searchSchema>
77
- const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
77
+ const collectionDef = getMultiCollectionDefinition(collection) as MultiCollectionDefinition
78
78
  const { t } = useTranslation('byline-admin')
79
79
  const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig()
80
80
 
@@ -8,18 +8,15 @@
8
8
 
9
9
  import { createFileRoute, notFound } from '@tanstack/react-router'
10
10
 
11
- import type { CollectionDefinition } from '@byline/core'
12
- import {
13
- buildInitialDataFromFields,
14
- getCollectionAdminConfig,
15
- getCollectionDefinition,
16
- } from '@byline/core'
11
+ import type { MultiCollectionDefinition } from '@byline/core'
12
+ import { buildInitialDataFromFields, getCollectionAdminConfig } from '@byline/core'
17
13
  import { useTranslation } from '@byline/i18n/react'
18
14
  import { z } from 'zod'
19
15
 
20
16
  import { BreadcrumbsClient } from '../admin-shell/chrome/breadcrumbs/breadcrumbs-client.js'
21
17
  import { CreateView } from '../admin-shell/collections/create.js'
22
18
  import { getAdminRoutePath } from './admin-path.js'
19
+ import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
23
20
 
24
21
  const searchSchema = z.object({
25
22
  /** URL-encoded list search state to return to on cancel — see list-return-state.ts. */
@@ -36,7 +33,7 @@ export function createCollectionCreateRoute(path: string) {
36
33
  params: { collection: string }
37
34
  // biome-ignore lint/suspicious/noExplicitAny: collection-specific shape
38
35
  }): Promise<{ initialData: any }> => {
39
- const collectionDef = getCollectionDefinition(params.collection)
36
+ const collectionDef = getMultiCollectionDefinition(params.collection)
40
37
  if (!collectionDef) {
41
38
  throw notFound()
42
39
  }
@@ -50,7 +47,7 @@ export function createCollectionCreateRoute(path: string) {
50
47
  const { collection } = Route.useParams() as { collection: string }
51
48
  // biome-ignore lint/suspicious/noExplicitAny: collection-specific shape
52
49
  const { initialData } = Route.useLoaderData() as { initialData: any }
53
- const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
50
+ const collectionDef = getMultiCollectionDefinition(collection) as MultiCollectionDefinition
54
51
  const adminConfig = getCollectionAdminConfig(collection)
55
52
  const { t } = useTranslation('byline-admin')
56
53
  const search = Route.useSearch() as z.infer<typeof searchSchema>
@@ -9,8 +9,8 @@
9
9
  import { useEffect, useRef, useState } from 'react'
10
10
  import { createFileRoute, notFound } from '@tanstack/react-router'
11
11
 
12
- import type { CollectionDefinition } from '@byline/core'
13
- import { getCollectionAdminConfig, getCollectionDefinition } from '@byline/core'
12
+ import type { MultiCollectionDefinition } from '@byline/core'
13
+ import { getCollectionAdminConfig } from '@byline/core'
14
14
  import { useTranslation } from '@byline/i18n/react'
15
15
  import { useToastManager } from '@byline/ui/react'
16
16
  import { z } from 'zod'
@@ -21,6 +21,7 @@ import { EditView } from '../admin-shell/collections/edit.js'
21
21
  import { getCollectionDocument } from '../server-fns/collections/index.js'
22
22
  import { getAdminRoutePath } from './admin-path.js'
23
23
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
24
+ import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
24
25
  import { decodeListReturnState } from './list-return-state.js'
25
26
  import { persistListReturnState, readListReturnState } from './list-return-storage.js'
26
27
 
@@ -46,7 +47,7 @@ export function createCollectionEditRoute(path: string) {
46
47
  params: { collection: string; id: string }
47
48
  deps: { locale?: string }
48
49
  }) => {
49
- const collectionDef = getCollectionDefinition(params.collection)
50
+ const collectionDef = getMultiCollectionDefinition(params.collection)
50
51
  if (!collectionDef) {
51
52
  throw notFound()
52
53
  }
@@ -77,7 +78,7 @@ export function createCollectionEditRoute(path: string) {
77
78
  const { collection, id } = Route.useParams() as { collection: string; id: string }
78
79
  const search = Route.useSearch() as z.infer<typeof searchSchema>
79
80
  const { locale } = search
80
- const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
81
+ const collectionDef = getMultiCollectionDefinition(collection) as MultiCollectionDefinition
81
82
  const adminConfig = getCollectionAdminConfig(collection)
82
83
  const { t } = useTranslation('byline-admin')
83
84
  const toastManager = useToastManager()
@@ -8,12 +8,8 @@
8
8
 
9
9
  import { createFileRoute, notFound } from '@tanstack/react-router'
10
10
 
11
- import type { CollectionDefinition } from '@byline/core'
12
- import {
13
- getCollectionAdminConfig,
14
- getCollectionDefinition,
15
- getWorkflowStatuses,
16
- } from '@byline/core'
11
+ import type { MultiCollectionDefinition } from '@byline/core'
12
+ import { getCollectionAdminConfig, getWorkflowStatuses } from '@byline/core'
17
13
  import { useTranslation } from '@byline/i18n/react'
18
14
  import { z } from 'zod'
19
15
 
@@ -26,6 +22,7 @@ import {
26
22
  } from '../server-fns/collections/index.js'
27
23
  import { getAdminRoutePath } from './admin-path.js'
28
24
  import { getContentLocaleRouteConfig } from './get-content-locale-route-config.js'
25
+ import { getMultiCollectionDefinition } from './get-multi-collection-definition.js'
29
26
 
30
27
  const searchSchema = z.object({
31
28
  page: z.coerce.number().min(1).optional(),
@@ -68,7 +65,7 @@ export function createCollectionHistoryRoute(path: string) {
68
65
  locale?: string
69
66
  }
70
67
  }) => {
71
- const collectionDef = getCollectionDefinition(params.collection)
68
+ const collectionDef = getMultiCollectionDefinition(params.collection)
72
69
  if (!collectionDef) {
73
70
  throw notFound()
74
71
  }
@@ -111,7 +108,7 @@ export function createCollectionHistoryRoute(path: string) {
111
108
  component: function CollectionHistoryComponent() {
112
109
  const { history, currentDocument, auditLog } = Route.useLoaderData()
113
110
  const { collection } = Route.useParams() as { collection: string; id: string }
114
- const collectionDef = getCollectionDefinition(collection) as CollectionDefinition
111
+ const collectionDef = getMultiCollectionDefinition(collection) as MultiCollectionDefinition
115
112
  const adminConfig = getCollectionAdminConfig(collection)
116
113
  const { t } = useTranslation('byline-admin')
117
114
  const { contentLocales, defaultContentLocale } = getContentLocaleRouteConfig()