@byline/host-tanstack-start 4.15.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 (183) hide show
  1. package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +2 -1
  2. package/dist/admin-shell/chrome/admin-menu-drawer.js +17 -5
  3. package/dist/admin-shell/chrome/dashboard.js +41 -5
  4. package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
  5. package/dist/admin-shell/collections/api.d.ts +2 -2
  6. package/dist/admin-shell/collections/create.d.ts +2 -2
  7. package/dist/admin-shell/collections/create.js +1 -0
  8. package/dist/admin-shell/collections/edit.d.ts +2 -2
  9. package/dist/admin-shell/collections/edit.js +1 -0
  10. package/dist/admin-shell/collections/history.d.ts +4 -12
  11. package/dist/admin-shell/collections/history.js +112 -303
  12. package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
  13. package/dist/admin-shell/collections/reindex-button.js +5 -1
  14. package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
  15. package/dist/admin-shell/collections/restore-version-modal.js +8 -24
  16. package/dist/admin-shell/collections/version-history.d.ts +72 -0
  17. package/dist/admin-shell/collections/version-history.js +259 -0
  18. package/dist/admin-shell/singletons/history.d.ts +19 -0
  19. package/dist/admin-shell/singletons/history.js +170 -0
  20. package/dist/admin-shell/singletons/history.test.d.ts +8 -0
  21. package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
  22. package/dist/admin-shell/singletons/preview-link.js +54 -0
  23. package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
  24. package/dist/admin-shell/singletons/view-menu.js +116 -0
  25. package/dist/admin-shell/singletons/view.d.ts +18 -0
  26. package/dist/admin-shell/singletons/view.js +259 -0
  27. package/dist/admin-shell/singletons/view.test.d.ts +8 -0
  28. package/dist/integrations/analytics-agent.d.ts +20 -0
  29. package/dist/integrations/analytics-agent.js +57 -0
  30. package/dist/integrations/analytics-events.d.ts +51 -0
  31. package/dist/integrations/analytics-events.js +129 -0
  32. package/dist/integrations/api-utils.d.ts +18 -4
  33. package/dist/integrations/api-utils.js +10 -3
  34. package/dist/integrations/serve-analytics-agent.d.ts +8 -0
  35. package/dist/integrations/serve-analytics-agent.js +11 -0
  36. package/dist/routes/analytics-range.d.ts +12 -0
  37. package/dist/routes/analytics-range.js +35 -0
  38. package/dist/routes/create-admin-analytics-route.d.ts +8 -0
  39. package/dist/routes/create-admin-analytics-route.js +108 -0
  40. package/dist/routes/create-admin-dashboard-route.js +2 -2
  41. package/dist/routes/create-admin-layout-route.js +10 -4
  42. package/dist/routes/create-collection-api-route.js +3 -3
  43. package/dist/routes/create-collection-create-route.js +4 -3
  44. package/dist/routes/create-collection-edit-route.js +4 -3
  45. package/dist/routes/create-collection-history-route.js +4 -3
  46. package/dist/routes/create-collection-list-route.js +4 -3
  47. package/dist/routes/create-singleton-history-route.d.ts +8 -0
  48. package/dist/routes/create-singleton-history-route.js +99 -0
  49. package/dist/routes/create-singleton-route.d.ts +35 -0
  50. package/dist/routes/create-singleton-route.js +130 -0
  51. package/dist/routes/get-multi-collection-definition.d.ts +10 -0
  52. package/dist/routes/get-multi-collection-definition.js +6 -0
  53. package/dist/routes/get-singleton-definition.d.ts +10 -0
  54. package/dist/routes/get-singleton-definition.js +6 -0
  55. package/dist/routes/index.d.ts +3 -0
  56. package/dist/routes/index.js +3 -0
  57. package/dist/server-fns/actors.d.ts +16 -0
  58. package/dist/server-fns/actors.js +25 -0
  59. package/dist/server-fns/admin-document-presentation.d.ts +30 -0
  60. package/dist/server-fns/admin-document-presentation.js +32 -0
  61. package/dist/server-fns/analytics/index.d.ts +9 -0
  62. package/dist/server-fns/analytics/index.js +2 -0
  63. package/dist/server-fns/analytics/maintenance.d.ts +22 -0
  64. package/dist/server-fns/analytics/maintenance.js +42 -0
  65. package/dist/server-fns/analytics/queries.d.ts +25 -0
  66. package/dist/server-fns/analytics/queries.js +54 -0
  67. package/dist/server-fns/collections/actors.d.ts +1 -10
  68. package/dist/server-fns/collections/actors.js +1 -25
  69. package/dist/server-fns/collections/get.js +9 -26
  70. package/dist/server-fns/collections/reorder.js +1 -1
  71. package/dist/server-fns/collections/scheduled-publication.js +2 -2
  72. package/dist/server-fns/collections/upload.js +3 -3
  73. package/dist/server-fns/collections/utils.d.ts +1 -7
  74. package/dist/server-fns/collections/utils.js +1 -4
  75. package/dist/server-fns/serialise.d.ts +14 -0
  76. package/dist/server-fns/serialise.js +4 -0
  77. package/dist/server-fns/singleton-document-read.d.ts +20 -0
  78. package/dist/server-fns/singleton-document-read.js +10 -0
  79. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
  80. package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
  81. package/dist/server-fns/singletons/change-status.d.ts +14 -0
  82. package/dist/server-fns/singletons/change-status.js +7 -0
  83. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
  84. package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
  85. package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
  86. package/dist/server-fns/singletons/copy-to-locale.js +11 -0
  87. package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
  88. package/dist/server-fns/singletons/find-by-version.js +9 -0
  89. package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
  90. package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
  91. package/dist/server-fns/singletons/get.d.ts +24 -0
  92. package/dist/server-fns/singletons/get.js +6 -0
  93. package/dist/server-fns/singletons/history.d.ts +31 -0
  94. package/dist/server-fns/singletons/history.js +22 -0
  95. package/dist/server-fns/singletons/index.d.ts +19 -0
  96. package/dist/server-fns/singletons/index.js +12 -0
  97. package/dist/server-fns/singletons/restore-version.d.ts +14 -0
  98. package/dist/server-fns/singletons/restore-version.js +7 -0
  99. package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
  100. package/dist/server-fns/singletons/schedule-publish.js +10 -0
  101. package/dist/server-fns/singletons/unpublish.d.ts +12 -0
  102. package/dist/server-fns/singletons/unpublish.js +7 -0
  103. package/dist/server-fns/singletons/update.d.ts +14 -0
  104. package/dist/server-fns/singletons/update.js +14 -0
  105. package/dist/vite/analytics-dev.d.ts +22 -0
  106. package/dist/vite/analytics-dev.js +28 -0
  107. package/dist/vite/index.d.ts +1 -0
  108. package/dist/vite/index.js +1 -0
  109. package/dist/vitest-jsdom.test.d.ts +8 -0
  110. package/package.json +28 -16
  111. package/src/admin-shell/chrome/admin-menu-drawer.tsx +19 -5
  112. package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
  113. package/src/admin-shell/chrome/dashboard.tsx +43 -9
  114. package/src/admin-shell/collections/api.tsx +2 -2
  115. package/src/admin-shell/collections/create.tsx +6 -2
  116. package/src/admin-shell/collections/edit.tsx +6 -2
  117. package/src/admin-shell/collections/history.module.css +1 -1
  118. package/src/admin-shell/collections/history.tsx +162 -442
  119. package/src/admin-shell/collections/reindex-button.tsx +4 -2
  120. package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
  121. package/src/admin-shell/collections/version-history.tsx +349 -0
  122. package/src/admin-shell/singletons/history.test.tsx +407 -0
  123. package/src/admin-shell/singletons/history.tsx +185 -0
  124. package/src/admin-shell/singletons/preview-link.tsx +75 -0
  125. package/src/admin-shell/singletons/view-menu.tsx +136 -0
  126. package/src/admin-shell/singletons/view.test.tsx +400 -0
  127. package/src/admin-shell/singletons/view.tsx +351 -0
  128. package/src/integrations/analytics-agent.tsx +91 -0
  129. package/src/integrations/analytics-events.test.node.ts +245 -0
  130. package/src/integrations/analytics-events.ts +224 -0
  131. package/src/integrations/api-utils.ts +34 -9
  132. package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
  133. package/src/integrations/serve-analytics-agent.ts +19 -0
  134. package/src/routes/analytics-range.ts +55 -0
  135. package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
  136. package/src/routes/create-admin-analytics-route.tsx +109 -0
  137. package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
  138. package/src/routes/create-admin-dashboard-route.tsx +10 -2
  139. package/src/routes/create-admin-layout-route.tsx +15 -8
  140. package/src/routes/create-collection-api-route.tsx +4 -4
  141. package/src/routes/create-collection-create-route.tsx +5 -8
  142. package/src/routes/create-collection-edit-route.tsx +5 -4
  143. package/src/routes/create-collection-history-route.tsx +5 -8
  144. package/src/routes/create-collection-list-route.tsx +5 -8
  145. package/src/routes/create-singleton-history-route.test.node.ts +148 -0
  146. package/src/routes/create-singleton-history-route.tsx +116 -0
  147. package/src/routes/create-singleton-route.test.node.ts +172 -0
  148. package/src/routes/create-singleton-route.tsx +176 -0
  149. package/src/routes/get-multi-collection-definition.ts +16 -0
  150. package/src/routes/get-singleton-definition.ts +16 -0
  151. package/src/routes/index.ts +3 -0
  152. package/src/server-fns/actors.ts +43 -0
  153. package/src/server-fns/admin-document-presentation.ts +78 -0
  154. package/src/server-fns/analytics/index.ts +23 -0
  155. package/src/server-fns/analytics/maintenance.ts +54 -0
  156. package/src/server-fns/analytics/queries.ts +86 -0
  157. package/src/server-fns/collections/actors.ts +1 -52
  158. package/src/server-fns/collections/get.ts +10 -49
  159. package/src/server-fns/collections/reorder.ts +1 -1
  160. package/src/server-fns/collections/scheduled-publication.ts +7 -1
  161. package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
  162. package/src/server-fns/collections/upload.ts +7 -3
  163. package/src/server-fns/collections/utils.ts +1 -9
  164. package/src/server-fns/serialise.ts +17 -0
  165. package/src/server-fns/singleton-document-read.ts +32 -0
  166. package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
  167. package/src/server-fns/singletons/change-status.ts +19 -0
  168. package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
  169. package/src/server-fns/singletons/copy-to-locale.ts +32 -0
  170. package/src/server-fns/singletons/find-by-version.ts +23 -0
  171. package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
  172. package/src/server-fns/singletons/get.ts +32 -0
  173. package/src/server-fns/singletons/history.ts +41 -0
  174. package/src/server-fns/singletons/index.ts +20 -0
  175. package/src/server-fns/singletons/restore-version.ts +19 -0
  176. package/src/server-fns/singletons/schedule-publish.ts +24 -0
  177. package/src/server-fns/singletons/singletons.test.node.ts +298 -0
  178. package/src/server-fns/singletons/unpublish.ts +19 -0
  179. package/src/server-fns/singletons/update.ts +31 -0
  180. package/src/vite/analytics-dev.test.node.ts +126 -0
  181. package/src/vite/analytics-dev.ts +56 -0
  182. package/src/vite/index.ts +1 -0
  183. 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
+ }
@@ -0,0 +1,91 @@
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 type React from 'react'
12
+ import { useEffect, useRef } from 'react'
13
+ import { useRouterState } from '@tanstack/react-router'
14
+
15
+ import { ANALYTICS_NAVIGATION_EVENT, ANALYTICS_STOP_EVENT } from '@byline/analytics-agent'
16
+
17
+ let pendingStopTimer: number | undefined
18
+
19
+ export interface AnalyticsAgentProps {
20
+ /** Application-owned same-origin route that receives browser events. */
21
+ endpoint: string
22
+ src?: string
23
+ cdnHosts?: readonly string[]
24
+ downloadExtensions?: readonly string[]
25
+ ignoredPathPrefixes?: readonly string[]
26
+ countSearchChanges?: boolean
27
+ countHashChanges?: boolean
28
+ }
29
+
30
+ /** Render the standalone script and bridge only committed router locations into it. */
31
+ export function AnalyticsAgent({
32
+ endpoint,
33
+ src = '/b.js',
34
+ cdnHosts,
35
+ downloadExtensions,
36
+ ignoredPathPrefixes,
37
+ countSearchChanges = false,
38
+ countHashChanges = false,
39
+ }: AnalyticsAgentProps): React.JSX.Element {
40
+ return (
41
+ <>
42
+ <script
43
+ defer
44
+ src={src}
45
+ data-endpoint={endpoint}
46
+ data-cdn-hosts={boundedAttribute(cdnHosts)}
47
+ data-download-extensions={boundedAttribute(downloadExtensions)}
48
+ data-ignore-prefixes={boundedAttribute(ignoredPathPrefixes)}
49
+ data-count-search={countSearchChanges ? 'true' : undefined}
50
+ data-count-hash={countHashChanges ? 'true' : undefined}
51
+ />
52
+ <AnalyticsNavigationBridge />
53
+ </>
54
+ )
55
+ }
56
+
57
+ function AnalyticsNavigationBridge(): null {
58
+ const href = useRouterState({ select: (state) => state.location.href })
59
+ const previousHref = useRef<string | undefined>(undefined)
60
+
61
+ useEffect(() => {
62
+ const previous = previousHref.current
63
+ previousHref.current = href
64
+ if (previous == null || previous === href) return
65
+ document.dispatchEvent(new CustomEvent(ANALYTICS_NAVIGATION_EVENT, { detail: href }))
66
+ }, [href])
67
+
68
+ useEffect(() => {
69
+ if (pendingStopTimer !== undefined) {
70
+ window.clearTimeout(pendingStopTimer)
71
+ pendingStopTimer = undefined
72
+ }
73
+
74
+ return () => {
75
+ // React Strict Mode immediately re-runs this effect after its simulated
76
+ // cleanup. Deferring the stop lets that setup cancel it, while a real
77
+ // consent-driven unmount leaves the timer in place.
78
+ pendingStopTimer = window.setTimeout(() => {
79
+ pendingStopTimer = undefined
80
+ document.dispatchEvent(new Event(ANALYTICS_STOP_EVENT))
81
+ }, 0)
82
+ }
83
+ }, [])
84
+
85
+ return null
86
+ }
87
+
88
+ function boundedAttribute(values: readonly string[] | undefined): string | undefined {
89
+ if (values == null || values.length === 0) return undefined
90
+ return values.slice(0, 64).join(',').slice(0, 2_048)
91
+ }
@@ -0,0 +1,245 @@
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 { describe, expect, it, vi } from 'vitest'
10
+
11
+ import {
12
+ createAnalyticsEventHandler,
13
+ localAnalyticsRequestContext,
14
+ trustedAnalyticsHeaders,
15
+ } from './analytics-events.js'
16
+
17
+ describe('createAnalyticsEventHandler', () => {
18
+ it('passes only explicit trusted request facts and emits no CORS response header', async () => {
19
+ const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
20
+ const handler = createAnalyticsEventHandler({
21
+ resolveRequestContext: trustedAnalyticsHeaders({
22
+ clientIpHeader: 'x-byline-client-ip',
23
+ countryHeader: 'x-byline-client-country',
24
+ }),
25
+ analytics: () => ({ ingest }),
26
+ })
27
+ const body = JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' })
28
+ const response = await handler(
29
+ new Request('https://www.example.com/api/events', {
30
+ method: 'POST',
31
+ body,
32
+ headers: {
33
+ origin: 'https://www.example.com',
34
+ 'user-agent': 'Mozilla/5.0',
35
+ 'x-byline-client-country': 'TH',
36
+ 'x-byline-client-ip': '203.0.113.10',
37
+ 'x-forwarded-for': '198.51.100.99',
38
+ },
39
+ })
40
+ )
41
+
42
+ expect(response.status).toBe(202)
43
+ expect(response.headers.has('access-control-allow-origin')).toBe(false)
44
+ expect(ingest).toHaveBeenCalledOnce()
45
+ expect(ingest.mock.calls[0]?.[0]).toMatchObject({
46
+ clientIp: '203.0.113.10',
47
+ country: 'TH',
48
+ origin: 'https://www.example.com',
49
+ })
50
+ expect(ingest.mock.calls[0]?.[0]).not.toHaveProperty('xForwardedFor')
51
+ })
52
+
53
+ it('stops reading an oversized stream and reports the contract body-size rejection', async () => {
54
+ const ingest = vi.fn().mockResolvedValue({ status: 400, accepted: false, reason: 'body-size' })
55
+ const handler = createAnalyticsEventHandler({
56
+ resolveRequestContext: trustedAnalyticsHeaders({
57
+ clientIpHeader: 'x-byline-client-ip',
58
+ }),
59
+ analytics: () => ({ ingest }),
60
+ })
61
+
62
+ const response = await handler(
63
+ new Request('https://www.example.com/api/events', {
64
+ method: 'POST',
65
+ body: 'x'.repeat(1_025),
66
+ headers: { 'x-byline-client-ip': '203.0.113.10' },
67
+ })
68
+ )
69
+
70
+ expect(response.status).toBe(400)
71
+ const received = ingest.mock.calls.at(0)?.at(0)
72
+ expect(received).toBeDefined()
73
+ expect((received?.body as Uint8Array | undefined)?.byteLength).toBe(1_025)
74
+ })
75
+
76
+ it('uses an explicit development identity only when the trusted header is absent', async () => {
77
+ const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
78
+ const handler = createAnalyticsEventHandler({
79
+ resolveRequestContext: trustedAnalyticsHeaders({
80
+ clientIpHeader: 'x-byline-client-ip',
81
+ fallback: { clientIp: 'vite-development' },
82
+ }),
83
+ analytics: () => ({ ingest }),
84
+ })
85
+ const body = JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' })
86
+
87
+ await handler(
88
+ new Request('http://localhost:5173/api/events', {
89
+ method: 'POST',
90
+ body,
91
+ })
92
+ )
93
+ await handler(
94
+ new Request('http://localhost:5173/api/events', {
95
+ method: 'POST',
96
+ body,
97
+ headers: { 'x-byline-client-ip': '203.0.113.10' },
98
+ })
99
+ )
100
+
101
+ expect(ingest.mock.calls[0]?.[0].clientIp).toBe('vite-development')
102
+ expect(ingest.mock.calls[1]?.[0].clientIp).toBe('203.0.113.10')
103
+ })
104
+
105
+ it.each(['127.0.0.1', '::1', '::ffff:127.0.0.1'])(
106
+ 'accepts direct loopback identity %s for a localhost production preview',
107
+ async (clientIp) => {
108
+ const resolver = localAnalyticsRequestContext({ resolveClientIp: () => clientIp })
109
+
110
+ await expect(resolver(new Request('http://localhost:5173/api/events'))).resolves.toEqual({
111
+ clientIp,
112
+ })
113
+ }
114
+ )
115
+
116
+ it('requires both a loopback URL and a loopback direct peer', async () => {
117
+ const loopbackPeer = localAnalyticsRequestContext({ resolveClientIp: () => '127.0.0.1' })
118
+ const publicPeer = localAnalyticsRequestContext({ resolveClientIp: () => '203.0.113.10' })
119
+
120
+ await expect(loopbackPeer(new Request('https://www.example.com/api/events'))).resolves.toEqual(
121
+ {}
122
+ )
123
+ await expect(publicPeer(new Request('http://localhost:5173/api/events'))).resolves.toEqual({})
124
+ })
125
+
126
+ it('prefers the trusted proxy header over a local fallback resolver', async () => {
127
+ const resolver = trustedAnalyticsHeaders({
128
+ clientIpHeader: 'x-byline-client-ip',
129
+ fallback: localAnalyticsRequestContext({ resolveClientIp: () => '127.0.0.1' }),
130
+ })
131
+
132
+ await expect(
133
+ resolver(
134
+ new Request('http://localhost:5173/api/events', {
135
+ headers: { 'x-byline-client-ip': '203.0.113.10' },
136
+ })
137
+ )
138
+ ).resolves.toEqual({ clientIp: '203.0.113.10', country: undefined })
139
+ })
140
+
141
+ it('rejects an empty development identity', () => {
142
+ expect(() =>
143
+ trustedAnalyticsHeaders({
144
+ clientIpHeader: 'x-byline-client-ip',
145
+ fallback: { clientIp: ' ' },
146
+ })
147
+ ).toThrow('fallback.clientIp must contain 1 to 255 characters')
148
+ })
149
+
150
+ it('rejects an invalid configured trust header before serving requests', () => {
151
+ expect(() =>
152
+ trustedAnalyticsHeaders({
153
+ clientIpHeader: 'not a header:',
154
+ })
155
+ ).toThrow('valid HTTP header name')
156
+ })
157
+
158
+ it('accepts deployment facts from a host resolver without assuming a proxy', async () => {
159
+ const ingest = vi.fn().mockResolvedValue({ status: 202, accepted: true })
160
+ const resolveRequestContext = vi.fn().mockReturnValue({
161
+ clientIp: 'direct-platform-client',
162
+ country: 'NZ',
163
+ })
164
+ const handler = createAnalyticsEventHandler({
165
+ resolveRequestContext,
166
+ analytics: () => ({ ingest }),
167
+ })
168
+ const request = new Request('https://www.example.com/telemetry/events', {
169
+ method: 'POST',
170
+ body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
171
+ })
172
+
173
+ expect((await handler(request)).status).toBe(202)
174
+ expect(resolveRequestContext).toHaveBeenCalledWith(request)
175
+ expect(ingest.mock.calls[0]?.[0]).toMatchObject({
176
+ clientIp: 'direct-platform-client',
177
+ country: 'NZ',
178
+ })
179
+ })
180
+
181
+ it('contains persistence failures as an empty 503 and logs no request facts', async () => {
182
+ const visitorHash = 'a'.repeat(64)
183
+ const error = Object.assign(new Error('new row violates analytics constraint'), {
184
+ code: '23514',
185
+ detail: `Failing row contains visitor ${visitorHash}`,
186
+ })
187
+ const ingest = vi.fn().mockRejectedValue(error)
188
+ const logError = vi.fn()
189
+ const handler = createAnalyticsEventHandler({
190
+ resolveRequestContext: trustedAnalyticsHeaders({
191
+ clientIpHeader: 'x-byline-client-ip',
192
+ }),
193
+ analytics: () => ({ ingest }),
194
+ logger: () => ({ error: logError }),
195
+ })
196
+
197
+ const response = await handler(
198
+ new Request('https://www.example.com/api/events', {
199
+ method: 'POST',
200
+ body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
201
+ headers: {
202
+ origin: 'https://www.example.com',
203
+ 'user-agent': 'Mozilla/5.0',
204
+ 'x-byline-client-ip': '203.0.113.10',
205
+ },
206
+ })
207
+ )
208
+
209
+ expect(response.status).toBe(503)
210
+ expect(await response.text()).toBe('')
211
+ expect(logError).toHaveBeenCalledWith(
212
+ { errorCode: '23514' },
213
+ '[analytics] event persistence failed'
214
+ )
215
+ const logged = JSON.stringify(logError.mock.calls)
216
+ expect(logged).not.toContain('203.0.113.10')
217
+ expect(logged).not.toContain(visitorHash)
218
+ expect(logged).not.toContain(error.message)
219
+ expect(logged).not.toContain(error.detail)
220
+ })
221
+
222
+ it('does not trust arbitrary persistence error codes', async () => {
223
+ const ingest = vi.fn().mockRejectedValue({ code: 'unsafe value: 203.0.113.10' })
224
+ const logError = vi.fn()
225
+ const handler = createAnalyticsEventHandler({
226
+ resolveRequestContext: trustedAnalyticsHeaders({
227
+ clientIpHeader: 'x-byline-client-ip',
228
+ }),
229
+ analytics: () => ({ ingest }),
230
+ logger: () => ({ error: logError }),
231
+ })
232
+
233
+ await handler(
234
+ new Request('https://www.example.com/api/events', {
235
+ method: 'POST',
236
+ body: JSON.stringify({ v: 1, kind: 'page', path: '/', ref: '' }),
237
+ })
238
+ )
239
+
240
+ expect(logError).toHaveBeenCalledWith(
241
+ { errorCode: 'UNKNOWN' },
242
+ '[analytics] event persistence failed'
243
+ )
244
+ })
245
+ })