@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.
- package/dist/admin-shell/chrome/admin-menu-drawer.d.ts +2 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +17 -5
- package/dist/admin-shell/chrome/dashboard.js +41 -5
- package/dist/admin-shell/chrome/dashboard.test.d.ts +8 -0
- package/dist/admin-shell/collections/api.d.ts +2 -2
- package/dist/admin-shell/collections/create.d.ts +2 -2
- package/dist/admin-shell/collections/create.js +1 -0
- package/dist/admin-shell/collections/edit.d.ts +2 -2
- package/dist/admin-shell/collections/edit.js +1 -0
- package/dist/admin-shell/collections/history.d.ts +4 -12
- package/dist/admin-shell/collections/history.js +112 -303
- package/dist/admin-shell/collections/reindex-button.d.ts +1 -1
- package/dist/admin-shell/collections/reindex-button.js +5 -1
- package/dist/admin-shell/collections/restore-version-modal.d.ts +4 -4
- package/dist/admin-shell/collections/restore-version-modal.js +8 -24
- package/dist/admin-shell/collections/version-history.d.ts +72 -0
- package/dist/admin-shell/collections/version-history.js +259 -0
- package/dist/admin-shell/singletons/history.d.ts +19 -0
- package/dist/admin-shell/singletons/history.js +170 -0
- package/dist/admin-shell/singletons/history.test.d.ts +8 -0
- package/dist/admin-shell/singletons/preview-link.d.ts +12 -0
- package/dist/admin-shell/singletons/preview-link.js +54 -0
- package/dist/admin-shell/singletons/view-menu.d.ts +13 -0
- package/dist/admin-shell/singletons/view-menu.js +116 -0
- package/dist/admin-shell/singletons/view.d.ts +18 -0
- package/dist/admin-shell/singletons/view.js +259 -0
- package/dist/admin-shell/singletons/view.test.d.ts +8 -0
- package/dist/integrations/analytics-agent.d.ts +20 -0
- package/dist/integrations/analytics-agent.js +57 -0
- package/dist/integrations/analytics-events.d.ts +51 -0
- package/dist/integrations/analytics-events.js +129 -0
- package/dist/integrations/api-utils.d.ts +18 -4
- package/dist/integrations/api-utils.js +10 -3
- package/dist/integrations/serve-analytics-agent.d.ts +8 -0
- package/dist/integrations/serve-analytics-agent.js +11 -0
- package/dist/routes/analytics-range.d.ts +12 -0
- package/dist/routes/analytics-range.js +35 -0
- package/dist/routes/create-admin-analytics-route.d.ts +8 -0
- package/dist/routes/create-admin-analytics-route.js +108 -0
- package/dist/routes/create-admin-dashboard-route.js +2 -2
- package/dist/routes/create-admin-layout-route.js +10 -4
- package/dist/routes/create-collection-api-route.js +3 -3
- package/dist/routes/create-collection-create-route.js +4 -3
- package/dist/routes/create-collection-edit-route.js +4 -3
- package/dist/routes/create-collection-history-route.js +4 -3
- package/dist/routes/create-collection-list-route.js +4 -3
- package/dist/routes/create-singleton-history-route.d.ts +8 -0
- package/dist/routes/create-singleton-history-route.js +99 -0
- package/dist/routes/create-singleton-route.d.ts +35 -0
- package/dist/routes/create-singleton-route.js +130 -0
- package/dist/routes/get-multi-collection-definition.d.ts +10 -0
- package/dist/routes/get-multi-collection-definition.js +6 -0
- package/dist/routes/get-singleton-definition.d.ts +10 -0
- package/dist/routes/get-singleton-definition.js +6 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.js +3 -0
- package/dist/server-fns/actors.d.ts +16 -0
- package/dist/server-fns/actors.js +25 -0
- package/dist/server-fns/admin-document-presentation.d.ts +30 -0
- package/dist/server-fns/admin-document-presentation.js +32 -0
- package/dist/server-fns/analytics/index.d.ts +9 -0
- package/dist/server-fns/analytics/index.js +2 -0
- package/dist/server-fns/analytics/maintenance.d.ts +22 -0
- package/dist/server-fns/analytics/maintenance.js +42 -0
- package/dist/server-fns/analytics/queries.d.ts +25 -0
- package/dist/server-fns/analytics/queries.js +54 -0
- package/dist/server-fns/collections/actors.d.ts +1 -10
- package/dist/server-fns/collections/actors.js +1 -25
- package/dist/server-fns/collections/get.js +9 -26
- package/dist/server-fns/collections/reorder.js +1 -1
- package/dist/server-fns/collections/scheduled-publication.js +2 -2
- package/dist/server-fns/collections/upload.js +3 -3
- package/dist/server-fns/collections/utils.d.ts +1 -7
- package/dist/server-fns/collections/utils.js +1 -4
- package/dist/server-fns/serialise.d.ts +14 -0
- package/dist/server-fns/serialise.js +4 -0
- package/dist/server-fns/singleton-document-read.d.ts +20 -0
- package/dist/server-fns/singleton-document-read.js +10 -0
- package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +12 -0
- package/dist/server-fns/singletons/cancel-scheduled-publish.js +7 -0
- package/dist/server-fns/singletons/change-status.d.ts +14 -0
- package/dist/server-fns/singletons/change-status.js +7 -0
- package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +14 -0
- package/dist/server-fns/singletons/confirm-scheduled-publish.js +9 -0
- package/dist/server-fns/singletons/copy-to-locale.d.ts +18 -0
- package/dist/server-fns/singletons/copy-to-locale.js +11 -0
- package/dist/server-fns/singletons/find-by-version.d.ts +16 -0
- package/dist/server-fns/singletons/find-by-version.js +9 -0
- package/dist/server-fns/singletons/get-scheduled-publish.d.ts +12 -0
- package/dist/server-fns/singletons/get-scheduled-publish.js +7 -0
- package/dist/server-fns/singletons/get.d.ts +24 -0
- package/dist/server-fns/singletons/get.js +6 -0
- package/dist/server-fns/singletons/history.d.ts +31 -0
- package/dist/server-fns/singletons/history.js +22 -0
- package/dist/server-fns/singletons/index.d.ts +19 -0
- package/dist/server-fns/singletons/index.js +12 -0
- package/dist/server-fns/singletons/restore-version.d.ts +14 -0
- package/dist/server-fns/singletons/restore-version.js +7 -0
- package/dist/server-fns/singletons/schedule-publish.d.ts +16 -0
- package/dist/server-fns/singletons/schedule-publish.js +10 -0
- package/dist/server-fns/singletons/unpublish.d.ts +12 -0
- package/dist/server-fns/singletons/unpublish.js +7 -0
- package/dist/server-fns/singletons/update.d.ts +14 -0
- package/dist/server-fns/singletons/update.js +14 -0
- package/dist/vite/analytics-dev.d.ts +22 -0
- package/dist/vite/analytics-dev.js +28 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +1 -0
- package/dist/vitest-jsdom.test.d.ts +8 -0
- package/package.json +28 -16
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +19 -5
- package/src/admin-shell/chrome/dashboard.test.tsx +199 -0
- package/src/admin-shell/chrome/dashboard.tsx +43 -9
- package/src/admin-shell/collections/api.tsx +2 -2
- package/src/admin-shell/collections/create.tsx +6 -2
- package/src/admin-shell/collections/edit.tsx +6 -2
- package/src/admin-shell/collections/history.module.css +1 -1
- package/src/admin-shell/collections/history.tsx +162 -442
- package/src/admin-shell/collections/reindex-button.tsx +4 -2
- package/src/admin-shell/collections/restore-version-modal.tsx +19 -25
- package/src/admin-shell/collections/version-history.tsx +349 -0
- package/src/admin-shell/singletons/history.test.tsx +407 -0
- package/src/admin-shell/singletons/history.tsx +185 -0
- package/src/admin-shell/singletons/preview-link.tsx +75 -0
- package/src/admin-shell/singletons/view-menu.tsx +136 -0
- package/src/admin-shell/singletons/view.test.tsx +400 -0
- package/src/admin-shell/singletons/view.tsx +351 -0
- package/src/integrations/analytics-agent.tsx +91 -0
- package/src/integrations/analytics-events.test.node.ts +245 -0
- package/src/integrations/analytics-events.ts +224 -0
- package/src/integrations/api-utils.ts +34 -9
- package/src/integrations/serve-analytics-agent.test.node.ts +20 -0
- package/src/integrations/serve-analytics-agent.ts +19 -0
- package/src/routes/analytics-range.ts +55 -0
- package/src/routes/create-admin-analytics-route.test.node.ts +46 -0
- package/src/routes/create-admin-analytics-route.tsx +109 -0
- package/src/routes/create-admin-dashboard-route.test.node.ts +70 -0
- package/src/routes/create-admin-dashboard-route.tsx +10 -2
- package/src/routes/create-admin-layout-route.tsx +15 -8
- package/src/routes/create-collection-api-route.tsx +4 -4
- package/src/routes/create-collection-create-route.tsx +5 -8
- package/src/routes/create-collection-edit-route.tsx +5 -4
- package/src/routes/create-collection-history-route.tsx +5 -8
- package/src/routes/create-collection-list-route.tsx +5 -8
- package/src/routes/create-singleton-history-route.test.node.ts +148 -0
- package/src/routes/create-singleton-history-route.tsx +116 -0
- package/src/routes/create-singleton-route.test.node.ts +172 -0
- package/src/routes/create-singleton-route.tsx +176 -0
- package/src/routes/get-multi-collection-definition.ts +16 -0
- package/src/routes/get-singleton-definition.ts +16 -0
- package/src/routes/index.ts +3 -0
- package/src/server-fns/actors.ts +43 -0
- package/src/server-fns/admin-document-presentation.ts +78 -0
- package/src/server-fns/analytics/index.ts +23 -0
- package/src/server-fns/analytics/maintenance.ts +54 -0
- package/src/server-fns/analytics/queries.ts +86 -0
- package/src/server-fns/collections/actors.ts +1 -52
- package/src/server-fns/collections/get.ts +10 -49
- package/src/server-fns/collections/reorder.ts +1 -1
- package/src/server-fns/collections/scheduled-publication.ts +7 -1
- package/src/server-fns/collections/upload-singleton.test.node.ts +164 -0
- package/src/server-fns/collections/upload.ts +7 -3
- package/src/server-fns/collections/utils.ts +1 -9
- package/src/server-fns/serialise.ts +17 -0
- package/src/server-fns/singleton-document-read.ts +32 -0
- package/src/server-fns/singletons/cancel-scheduled-publish.ts +19 -0
- package/src/server-fns/singletons/change-status.ts +19 -0
- package/src/server-fns/singletons/confirm-scheduled-publish.ts +23 -0
- package/src/server-fns/singletons/copy-to-locale.ts +32 -0
- package/src/server-fns/singletons/find-by-version.ts +23 -0
- package/src/server-fns/singletons/get-scheduled-publish.ts +19 -0
- package/src/server-fns/singletons/get.ts +32 -0
- package/src/server-fns/singletons/history.ts +41 -0
- package/src/server-fns/singletons/index.ts +20 -0
- package/src/server-fns/singletons/restore-version.ts +19 -0
- package/src/server-fns/singletons/schedule-publish.ts +24 -0
- package/src/server-fns/singletons/singletons.test.node.ts +298 -0
- package/src/server-fns/singletons/unpublish.ts +19 -0
- package/src/server-fns/singletons/update.ts +31 -0
- package/src/vite/analytics-dev.test.node.ts +126 -0
- package/src/vite/analytics-dev.ts +56 -0
- package/src/vite/index.ts +1 -0
- 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
|
+
})
|