@byline/host-tanstack-start 4.18.0 → 4.19.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/collections/create.js +22 -5
- package/dist/admin-shell/collections/edit.js +85 -17
- package/dist/admin-shell/collections/restore-version-modal.js +14 -2
- package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
- package/dist/admin-shell/singletons/view.js +10 -4
- package/dist/server-fns/collections/copy-to-locale.d.ts +2 -1
- package/dist/server-fns/collections/copy-to-locale.js +13 -6
- package/dist/server-fns/collections/create.d.ts +1 -1
- package/dist/server-fns/collections/create.js +15 -7
- package/dist/server-fns/collections/delete-locale.d.ts +2 -1
- package/dist/server-fns/collections/delete-locale.js +11 -4
- package/dist/server-fns/collections/duplicate.d.ts +2 -1
- package/dist/server-fns/collections/duplicate.js +10 -3
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/restore-version.d.ts +1 -1
- package/dist/server-fns/collections/restore-version.js +17 -10
- package/dist/server-fns/collections/save-outcome.d.ts +23 -0
- package/dist/server-fns/collections/save-outcome.js +18 -0
- package/dist/server-fns/collections/update.d.ts +3 -1
- package/dist/server-fns/collections/update.js +17 -7
- package/dist/server-fns/singletons/copy-to-locale.js +14 -5
- package/dist/server-fns/singletons/restore-version.js +10 -1
- package/dist/server-fns/singletons/update.js +12 -5
- package/package.json +10 -10
- package/src/admin-shell/collections/create.tsx +22 -3
- package/src/admin-shell/collections/edit.tsx +63 -10
- package/src/admin-shell/collections/restore-version-modal.tsx +11 -2
- package/src/admin-shell/collections/save-navigation.test.tsx +384 -0
- package/src/admin-shell/singletons/history.test.tsx +43 -0
- package/src/admin-shell/singletons/view.test.tsx +27 -0
- package/src/admin-shell/singletons/view.tsx +35 -16
- package/src/server-fns/collections/copy-to-locale.ts +43 -29
- package/src/server-fns/collections/create.ts +16 -8
- package/src/server-fns/collections/delete-locale.ts +38 -24
- package/src/server-fns/collections/duplicate.ts +38 -24
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/restore-version.ts +16 -9
- package/src/server-fns/collections/save-outcome.test.node.ts +77 -0
- package/src/server-fns/collections/save-outcome.ts +55 -0
- package/src/server-fns/collections/update.ts +23 -8
- package/src/server-fns/singletons/copy-to-locale.ts +16 -9
- package/src/server-fns/singletons/restore-version.ts +12 -3
- package/src/server-fns/singletons/singletons.test.node.ts +34 -0
- package/src/server-fns/singletons/update.ts +12 -5
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type React from 'react'
|
|
10
|
+
import { act } from 'react'
|
|
11
|
+
|
|
12
|
+
import { type MultiCollectionDefinition, SINGLE_STATUS_WORKFLOW } from '@byline/core'
|
|
13
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
14
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
15
|
+
|
|
16
|
+
const mocks = vi.hoisted(() => ({
|
|
17
|
+
copyToLocale: vi.fn(),
|
|
18
|
+
create: vi.fn(),
|
|
19
|
+
deleteLocale: vi.fn(),
|
|
20
|
+
duplicate: vi.fn(),
|
|
21
|
+
navigate: vi.fn(),
|
|
22
|
+
formProps: [] as Array<Record<string, unknown>>,
|
|
23
|
+
toastAdd: vi.fn(),
|
|
24
|
+
update: vi.fn(),
|
|
25
|
+
updateSystemFields: vi.fn(),
|
|
26
|
+
}))
|
|
27
|
+
|
|
28
|
+
vi.mock('@byline/admin/react', () => ({
|
|
29
|
+
FormRenderer: (props: Record<string, unknown>) => {
|
|
30
|
+
mocks.formProps.push(props)
|
|
31
|
+
return <div data-testid="form-renderer" />
|
|
32
|
+
},
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
vi.mock('@byline/i18n/react', () => ({
|
|
36
|
+
useTranslation: () => ({
|
|
37
|
+
t: (key: string) => key,
|
|
38
|
+
}),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
vi.mock('@byline/ui/react', () => {
|
|
42
|
+
const Pass = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>
|
|
43
|
+
return {
|
|
44
|
+
Container: Pass,
|
|
45
|
+
Section: Pass,
|
|
46
|
+
useToastManager: () => ({ add: mocks.toastAdd }),
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
vi.mock('../../routes/admin-path.js', () => ({
|
|
51
|
+
getAdminRoutePath: (...parts: string[]) => `/admin/${parts.join('/')}`,
|
|
52
|
+
}))
|
|
53
|
+
|
|
54
|
+
vi.mock('../../routes/list-return-state.js', () => ({
|
|
55
|
+
decodeListReturnState: () => ({ page: 2 }),
|
|
56
|
+
}))
|
|
57
|
+
|
|
58
|
+
vi.mock('../../routes/list-return-storage.js', () => ({
|
|
59
|
+
clearListReturnState: vi.fn(),
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
vi.mock('../../server-fns/collections/index.js', () => ({
|
|
63
|
+
cancelCollectionDocumentScheduledPublish: vi.fn(),
|
|
64
|
+
confirmCollectionDocumentScheduledPublish: vi.fn(),
|
|
65
|
+
copyDocumentToLocale: mocks.copyToLocale,
|
|
66
|
+
createCollectionDocument: mocks.create,
|
|
67
|
+
deleteDocument: vi.fn(),
|
|
68
|
+
deleteDocumentLocale: mocks.deleteLocale,
|
|
69
|
+
duplicateCollectionDocument: mocks.duplicate,
|
|
70
|
+
hasCommittedDocumentHookFailure: (result: { status: string }) =>
|
|
71
|
+
result.status === 'committed-hook-failed',
|
|
72
|
+
hasDeleteSideEffectFailures: () => false,
|
|
73
|
+
scheduleCollectionDocumentPublish: vi.fn(),
|
|
74
|
+
unpublishDocument: vi.fn(),
|
|
75
|
+
updateCollectionDocumentSystemFields: mocks.updateSystemFields,
|
|
76
|
+
updateCollectionDocumentWithPatches: mocks.update,
|
|
77
|
+
updateDocumentStatus: vi.fn(),
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
vi.mock('../chrome/loose-router.js', () => ({
|
|
81
|
+
useNavigate: () => mocks.navigate,
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
vi.mock('./tanstack-navigation-guard.js', () => ({
|
|
85
|
+
useTanStackNavigationGuard: () => ({
|
|
86
|
+
isBlocked: false,
|
|
87
|
+
stay: () => {},
|
|
88
|
+
proceed: () => {},
|
|
89
|
+
}),
|
|
90
|
+
}))
|
|
91
|
+
|
|
92
|
+
vi.mock('./view-menu.js', () => ({
|
|
93
|
+
ViewMenu: () => null,
|
|
94
|
+
}))
|
|
95
|
+
|
|
96
|
+
import { CreateView } from './create.js'
|
|
97
|
+
import { EditView } from './edit.js'
|
|
98
|
+
|
|
99
|
+
const collection = {
|
|
100
|
+
path: 'languages',
|
|
101
|
+
labels: { singular: 'Language', plural: 'Languages' },
|
|
102
|
+
workflow: SINGLE_STATUS_WORKFLOW,
|
|
103
|
+
fields: [{ name: 'name', label: 'Name', type: 'text' }],
|
|
104
|
+
} as MultiCollectionDefinition
|
|
105
|
+
|
|
106
|
+
const initialData = {
|
|
107
|
+
id: 'language-en',
|
|
108
|
+
versionId: 'version-1',
|
|
109
|
+
status: 'published',
|
|
110
|
+
fields: { name: 'English' },
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let container: HTMLDivElement
|
|
114
|
+
let root: Root
|
|
115
|
+
|
|
116
|
+
beforeAll(() => {
|
|
117
|
+
;(
|
|
118
|
+
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
119
|
+
).IS_REACT_ACT_ENVIRONMENT = true
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
vi.clearAllMocks()
|
|
124
|
+
mocks.formProps.length = 0
|
|
125
|
+
mocks.create.mockResolvedValue({
|
|
126
|
+
status: 'ok',
|
|
127
|
+
documentId: 'language-th',
|
|
128
|
+
documentVersionId: 'version-created',
|
|
129
|
+
})
|
|
130
|
+
mocks.update.mockResolvedValue({ status: 'ok' })
|
|
131
|
+
mocks.updateSystemFields.mockResolvedValue({ status: 'ok' })
|
|
132
|
+
mocks.copyToLocale.mockResolvedValue({
|
|
133
|
+
documentId: 'language-en',
|
|
134
|
+
documentVersionId: 'version-2',
|
|
135
|
+
sourceLocale: 'en',
|
|
136
|
+
targetLocale: 'th',
|
|
137
|
+
fieldsUpdated: 1,
|
|
138
|
+
})
|
|
139
|
+
mocks.deleteLocale.mockResolvedValue({
|
|
140
|
+
documentId: 'language-en',
|
|
141
|
+
documentVersionId: 'version-2',
|
|
142
|
+
locale: 'th',
|
|
143
|
+
})
|
|
144
|
+
mocks.duplicate.mockResolvedValue({
|
|
145
|
+
documentId: 'language-copy',
|
|
146
|
+
documentVersionId: 'version-copy',
|
|
147
|
+
sourceDocumentId: 'language-en',
|
|
148
|
+
newPath: 'english-copy',
|
|
149
|
+
pathRetried: false,
|
|
150
|
+
})
|
|
151
|
+
container = document.createElement('div')
|
|
152
|
+
document.body.appendChild(container)
|
|
153
|
+
root = createRoot(container)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
act(() => root.unmount())
|
|
158
|
+
container.remove()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
afterAll(() => {
|
|
162
|
+
delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
|
|
163
|
+
.IS_REACT_ACT_ENVIRONMENT
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
function latestSubmit(): (payload: Record<string, unknown>) => Promise<void> {
|
|
167
|
+
const onSubmit = mocks.formProps.at(-1)?.onSubmit
|
|
168
|
+
if (typeof onSubmit !== 'function') throw new Error('FormRenderer onSubmit not found')
|
|
169
|
+
return onSubmit as (payload: Record<string, unknown>) => Promise<void>
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function latestAction(name: string): (...args: any[]) => Promise<void> {
|
|
173
|
+
const action = mocks.formProps.at(-1)?.[name]
|
|
174
|
+
if (typeof action !== 'function') throw new Error(`FormRenderer ${name} not found`)
|
|
175
|
+
return action as (...args: any[]) => Promise<void>
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
describe('collection post-save navigation', () => {
|
|
179
|
+
it('bypasses the dirty-form blocker when create redirects to the new document', async () => {
|
|
180
|
+
act(() => {
|
|
181
|
+
root.render(<CreateView collectionDefinition={collection} from="page=2" />)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
await act(async () => {
|
|
185
|
+
await latestSubmit()({ data: { name: 'Thai' } })
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
expect(mocks.navigate).toHaveBeenCalledWith({
|
|
189
|
+
to: '/admin/collections/$collection/$id',
|
|
190
|
+
params: { collection: 'languages', id: 'language-th' },
|
|
191
|
+
search: { action: 'created', from: 'page=2' },
|
|
192
|
+
ignoreBlocker: true,
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('bypasses the dirty-form blocker when create falls back to the list', async () => {
|
|
197
|
+
mocks.create.mockResolvedValueOnce({ status: 'ok' })
|
|
198
|
+
act(() => {
|
|
199
|
+
root.render(<CreateView collectionDefinition={collection} from="page=2" />)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await act(async () => {
|
|
203
|
+
await latestSubmit()({ data: { name: 'Thai' } })
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
expect(mocks.navigate).toHaveBeenCalledWith({
|
|
207
|
+
to: '/admin/collections/$collection',
|
|
208
|
+
params: { collection: 'languages' },
|
|
209
|
+
search: { page: 2, action: 'created' },
|
|
210
|
+
ignoreBlocker: true,
|
|
211
|
+
})
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('does not bypass the blocker or navigate when create fails', async () => {
|
|
215
|
+
const error = new Error('create failed')
|
|
216
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
217
|
+
mocks.create.mockRejectedValueOnce(error)
|
|
218
|
+
act(() => {
|
|
219
|
+
root.render(<CreateView collectionDefinition={collection} />)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
await expect(latestSubmit()({ data: { name: 'Thai' } })).rejects.toBe(error)
|
|
223
|
+
|
|
224
|
+
expect(mocks.navigate).not.toHaveBeenCalled()
|
|
225
|
+
consoleError.mockRestore()
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('treats a committed create hook failure as saved and navigates with a warning', async () => {
|
|
229
|
+
mocks.create.mockResolvedValueOnce({
|
|
230
|
+
status: 'committed-hook-failed',
|
|
231
|
+
documentId: 'language-th',
|
|
232
|
+
documentVersionId: 'version-created',
|
|
233
|
+
sideEffectFailure: { phase: 'afterCreate', code: 'ERR_UNHANDLED' },
|
|
234
|
+
})
|
|
235
|
+
act(() => {
|
|
236
|
+
root.render(<CreateView collectionDefinition={collection} from="page=2" />)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
await act(async () => {
|
|
240
|
+
await latestSubmit()({ data: { name: 'Thai' } })
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
expect(mocks.toastAdd).toHaveBeenCalledWith({
|
|
244
|
+
title: 'collections.save.hookFailedToast',
|
|
245
|
+
description: 'collections.save.hookFailedDescription',
|
|
246
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
247
|
+
})
|
|
248
|
+
expect(mocks.navigate).toHaveBeenCalledWith({
|
|
249
|
+
to: '/admin/collections/$collection/$id',
|
|
250
|
+
params: { collection: 'languages', id: 'language-th' },
|
|
251
|
+
search: { from: 'page=2' },
|
|
252
|
+
ignoreBlocker: true,
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('bypasses the dirty-form blocker during the successful edit refresh', async () => {
|
|
257
|
+
act(() => {
|
|
258
|
+
root.render(
|
|
259
|
+
<EditView
|
|
260
|
+
collectionDefinition={collection}
|
|
261
|
+
initialData={initialData as never}
|
|
262
|
+
contentLocales={[{ code: 'en', label: 'English' }]}
|
|
263
|
+
defaultContentLocale="en"
|
|
264
|
+
locale="en"
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
await act(async () => {
|
|
270
|
+
await latestSubmit()({
|
|
271
|
+
data: { name: 'English' },
|
|
272
|
+
patches: [{ kind: 'field.set', path: 'name', value: 'English' }],
|
|
273
|
+
contentDirty: true,
|
|
274
|
+
pathDirty: false,
|
|
275
|
+
availableLocalesDirty: false,
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
expect(mocks.navigate).toHaveBeenCalledWith({
|
|
280
|
+
to: '/admin/collections/$collection/$id',
|
|
281
|
+
params: { collection: 'languages', id: 'language-en' },
|
|
282
|
+
search: expect.any(Function),
|
|
283
|
+
ignoreBlocker: true,
|
|
284
|
+
})
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('treats a committed update hook failure as saved and refreshes with a warning', async () => {
|
|
288
|
+
mocks.update.mockResolvedValueOnce({
|
|
289
|
+
status: 'committed-hook-failed',
|
|
290
|
+
documentId: 'language-en',
|
|
291
|
+
documentVersionId: 'version-2',
|
|
292
|
+
sideEffectFailure: { phase: 'afterUpdate', code: 'ERR_UNHANDLED' },
|
|
293
|
+
})
|
|
294
|
+
act(() => {
|
|
295
|
+
root.render(
|
|
296
|
+
<EditView
|
|
297
|
+
collectionDefinition={collection}
|
|
298
|
+
initialData={initialData as never}
|
|
299
|
+
contentLocales={[{ code: 'en', label: 'English' }]}
|
|
300
|
+
defaultContentLocale="en"
|
|
301
|
+
locale="en"
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
await act(async () => {
|
|
307
|
+
await latestSubmit()({
|
|
308
|
+
data: { name: 'English' },
|
|
309
|
+
patches: [{ kind: 'field.set', path: 'name', value: 'English' }],
|
|
310
|
+
contentDirty: true,
|
|
311
|
+
pathDirty: false,
|
|
312
|
+
availableLocalesDirty: false,
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
expect(mocks.toastAdd).toHaveBeenCalledWith({
|
|
317
|
+
title: 'collections.save.hookFailedToast',
|
|
318
|
+
description: 'collections.save.hookFailedDescription',
|
|
319
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
320
|
+
})
|
|
321
|
+
expect(mocks.navigate).toHaveBeenCalledWith(expect.objectContaining({ ignoreBlocker: true }))
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it.each([
|
|
325
|
+
{
|
|
326
|
+
action: 'onDuplicate',
|
|
327
|
+
invoke: () => latestAction('onDuplicate')(),
|
|
328
|
+
mock: mocks.duplicate,
|
|
329
|
+
phase: 'afterCreate',
|
|
330
|
+
expectedSearch: expect.any(Function),
|
|
331
|
+
expectedId: 'language-copy',
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
action: 'onCopyToLocale',
|
|
335
|
+
invoke: () => latestAction('onCopyToLocale')({ targetLocale: 'th', overwrite: false }),
|
|
336
|
+
mock: mocks.copyToLocale,
|
|
337
|
+
phase: 'afterUpdate',
|
|
338
|
+
expectedSearch: expect.any(Function),
|
|
339
|
+
expectedId: 'language-en',
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
action: 'onDeleteLocale',
|
|
343
|
+
invoke: () => latestAction('onDeleteLocale')({ targetLocale: 'th' }),
|
|
344
|
+
mock: mocks.deleteLocale,
|
|
345
|
+
phase: 'afterUpdate',
|
|
346
|
+
expectedSearch: expect.any(Function),
|
|
347
|
+
expectedId: 'language-en',
|
|
348
|
+
},
|
|
349
|
+
])('treats committed $action hook failures as completed mutations', async (testCase) => {
|
|
350
|
+
testCase.mock.mockResolvedValueOnce({
|
|
351
|
+
status: 'committed-hook-failed',
|
|
352
|
+
documentId: testCase.expectedId,
|
|
353
|
+
documentVersionId: 'version-committed',
|
|
354
|
+
sideEffectFailure: { phase: testCase.phase, code: 'ERR_UNHANDLED' },
|
|
355
|
+
})
|
|
356
|
+
act(() => {
|
|
357
|
+
root.render(
|
|
358
|
+
<EditView
|
|
359
|
+
collectionDefinition={collection}
|
|
360
|
+
initialData={initialData as never}
|
|
361
|
+
contentLocales={[
|
|
362
|
+
{ code: 'en', label: 'English' },
|
|
363
|
+
{ code: 'th', label: 'Thai' },
|
|
364
|
+
]}
|
|
365
|
+
defaultContentLocale="en"
|
|
366
|
+
locale="en"
|
|
367
|
+
/>
|
|
368
|
+
)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
await act(testCase.invoke)
|
|
372
|
+
|
|
373
|
+
expect(mocks.toastAdd).toHaveBeenCalledWith({
|
|
374
|
+
title: 'collections.save.hookFailedToast',
|
|
375
|
+
description: 'collections.save.hookFailedDescription',
|
|
376
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
377
|
+
})
|
|
378
|
+
expect(mocks.navigate).toHaveBeenCalledWith({
|
|
379
|
+
to: '/admin/collections/$collection/$id',
|
|
380
|
+
params: { collection: 'languages', id: testCase.expectedId },
|
|
381
|
+
search: testCase.expectedSearch,
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
})
|
|
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
21
21
|
findSingletonByVersion: vi.fn(async () => ({})),
|
|
22
22
|
pagerProps: [] as Array<{ componentName: string; page: number; count: number }>,
|
|
23
23
|
diffModalProps: [] as Array<Record<string, unknown>>,
|
|
24
|
+
toast: vi.fn(),
|
|
24
25
|
location: {
|
|
25
26
|
pathname: '/admin/singletons/site-settings/history',
|
|
26
27
|
search: {} as Record<string, unknown>,
|
|
@@ -103,6 +104,7 @@ vi.mock('@byline/ui/react', () => {
|
|
|
103
104
|
Section: Div,
|
|
104
105
|
Select: () => <select aria-label="page size" />,
|
|
105
106
|
Table,
|
|
107
|
+
useToastManager: () => ({ add: mocks.toast }),
|
|
106
108
|
}
|
|
107
109
|
})
|
|
108
110
|
|
|
@@ -404,4 +406,45 @@ describe('singleton version history', () => {
|
|
|
404
406
|
})
|
|
405
407
|
dispose()
|
|
406
408
|
})
|
|
409
|
+
|
|
410
|
+
it('closes and refreshes after a restore whose afterSave hook failed post-commit', async () => {
|
|
411
|
+
mocks.restoreSingleton.mockResolvedValueOnce({
|
|
412
|
+
status: 'committed-hook-failed',
|
|
413
|
+
documentId: 'document-settings',
|
|
414
|
+
documentVersionId: 'version-restored',
|
|
415
|
+
sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
|
|
416
|
+
})
|
|
417
|
+
render(
|
|
418
|
+
<SingletonHistoryView
|
|
419
|
+
singletonDefinition={singletonDefinition}
|
|
420
|
+
data={data}
|
|
421
|
+
currentDocument={currentDocument}
|
|
422
|
+
contentLocales={[]}
|
|
423
|
+
defaultContentLocale="en"
|
|
424
|
+
workflowStatuses={[]}
|
|
425
|
+
/>
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
const restore = [...container.querySelectorAll('button')].find(
|
|
429
|
+
(button) => button.textContent === 'Restore'
|
|
430
|
+
)
|
|
431
|
+
await act(async () => restore?.click())
|
|
432
|
+
const confirm = [...container.querySelectorAll('button')].find(
|
|
433
|
+
(button) => button.textContent === 'Restore as Published'
|
|
434
|
+
)
|
|
435
|
+
await act(async () => confirm?.click())
|
|
436
|
+
|
|
437
|
+
expect(mocks.toast).toHaveBeenCalledWith({
|
|
438
|
+
title: 'collections.save.hookFailedToast',
|
|
439
|
+
description: 'collections.save.hookFailedDescription',
|
|
440
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
441
|
+
})
|
|
442
|
+
expect(mocks.invalidate).toHaveBeenCalledOnce()
|
|
443
|
+
expect(mocks.navigate).toHaveBeenLastCalledWith({
|
|
444
|
+
to: '/admin/singletons/$singleton',
|
|
445
|
+
params: { singleton: 'site-settings' },
|
|
446
|
+
search: {},
|
|
447
|
+
})
|
|
448
|
+
dispose()
|
|
449
|
+
})
|
|
407
450
|
})
|
|
@@ -54,6 +54,14 @@ vi.mock('../../server-fns/singletons/index.js', () => ({
|
|
|
54
54
|
copySingletonToLocale: mocks.copyToLocale,
|
|
55
55
|
}))
|
|
56
56
|
|
|
57
|
+
vi.mock('../../server-fns/collections/index.js', () => ({
|
|
58
|
+
hasCommittedDocumentHookFailure: (result: unknown) =>
|
|
59
|
+
typeof result === 'object' &&
|
|
60
|
+
result != null &&
|
|
61
|
+
'status' in result &&
|
|
62
|
+
result.status === 'committed-hook-failed',
|
|
63
|
+
}))
|
|
64
|
+
|
|
57
65
|
vi.mock('../../server-fns/preview/index.js', () => ({
|
|
58
66
|
enablePreviewModeFn: vi.fn(async () => {}),
|
|
59
67
|
}))
|
|
@@ -370,6 +378,25 @@ describe('SingletonView', () => {
|
|
|
370
378
|
expect(container.textContent).toContain('Cancel')
|
|
371
379
|
})
|
|
372
380
|
|
|
381
|
+
it('treats a committed afterSave failure as saved, warns, and reloads canonical state', async () => {
|
|
382
|
+
mocks.update.mockResolvedValueOnce({
|
|
383
|
+
status: 'committed-hook-failed',
|
|
384
|
+
documentId: 'doc-settings',
|
|
385
|
+
documentVersionId: 'version-next',
|
|
386
|
+
sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
|
|
387
|
+
})
|
|
388
|
+
render(loadedDocument)
|
|
389
|
+
typeIntoTitle('Committed change')
|
|
390
|
+
await submit()
|
|
391
|
+
|
|
392
|
+
expect(latestToastDescription()).toContain(
|
|
393
|
+
'Your changes were saved, but a post-save update failed'
|
|
394
|
+
)
|
|
395
|
+
expect(mocks.invalidate).toHaveBeenCalledOnce()
|
|
396
|
+
expect(mocks.guardStates.at(-1)).toBe(false)
|
|
397
|
+
expect(container.textContent).toContain('Close')
|
|
398
|
+
})
|
|
399
|
+
|
|
373
400
|
it('re-runs the loader and remounts before unlocking saved-document uploads', async () => {
|
|
374
401
|
const loader = vi
|
|
375
402
|
.fn<() => Promise<typeof loadedDocument | null>>()
|
|
@@ -23,6 +23,10 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
23
23
|
import { Container, Section, useToastManager } from '@byline/ui/react'
|
|
24
24
|
|
|
25
25
|
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
26
|
+
import {
|
|
27
|
+
hasCommittedDocumentHookFailure,
|
|
28
|
+
type SerializedDocumentPublishSchedule,
|
|
29
|
+
} from '../../server-fns/collections/index.js'
|
|
26
30
|
import {
|
|
27
31
|
cancelSingletonScheduledPublish,
|
|
28
32
|
changeSingletonStatus,
|
|
@@ -35,7 +39,6 @@ import {
|
|
|
35
39
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
36
40
|
import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
|
|
37
41
|
import { SingletonViewMenu } from './view-menu.js'
|
|
38
|
-
import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
|
|
39
42
|
import type { ContentLocaleOption } from '../collections/view-menu.js'
|
|
40
43
|
|
|
41
44
|
type SingletonDocument = Record<string, any> & {
|
|
@@ -119,9 +122,17 @@ export function SingletonView({
|
|
|
119
122
|
)
|
|
120
123
|
}
|
|
121
124
|
|
|
125
|
+
const notifyCommittedHookFailure = () => {
|
|
126
|
+
toast(
|
|
127
|
+
t('collections.save.hookFailedToast'),
|
|
128
|
+
t('collections.save.hookFailedDescription'),
|
|
129
|
+
'warning'
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
122
133
|
const handleSubmit = async ({ data }: { data: Record<string, any> }) => {
|
|
123
134
|
try {
|
|
124
|
-
await updateSingleton({
|
|
135
|
+
const result = await updateSingleton({
|
|
125
136
|
data: {
|
|
126
137
|
singleton: path,
|
|
127
138
|
data,
|
|
@@ -130,11 +141,15 @@ export function SingletonView({
|
|
|
130
141
|
},
|
|
131
142
|
})
|
|
132
143
|
if (document != null) notifyScheduleSuspended()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
if (hasCommittedDocumentHookFailure(result)) {
|
|
145
|
+
notifyCommittedHookFailure()
|
|
146
|
+
} else {
|
|
147
|
+
toast(
|
|
148
|
+
t('singletons.edit.updateTitle', { label }),
|
|
149
|
+
t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
|
|
150
|
+
'success'
|
|
151
|
+
)
|
|
152
|
+
}
|
|
138
153
|
await reload()
|
|
139
154
|
} catch (err) {
|
|
140
155
|
const code = errorCode(err)
|
|
@@ -199,7 +214,7 @@ export function SingletonView({
|
|
|
199
214
|
overwrite: boolean
|
|
200
215
|
}) => {
|
|
201
216
|
try {
|
|
202
|
-
await copySingletonToLocale({
|
|
217
|
+
const result = await copySingletonToLocale({
|
|
203
218
|
data: {
|
|
204
219
|
singleton: path,
|
|
205
220
|
sourceLocale: locale ?? defaultContentLocale,
|
|
@@ -212,14 +227,18 @@ export function SingletonView({
|
|
|
212
227
|
contentLocales.find((entry) => entry.code === sourceLocale)?.label ?? sourceLocale
|
|
213
228
|
const targetLabel =
|
|
214
229
|
contentLocales.find((entry) => entry.code === targetLocale)?.label ?? targetLocale
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
230
|
+
if (hasCommittedDocumentHookFailure(result)) {
|
|
231
|
+
notifyCommittedHookFailure()
|
|
232
|
+
} else {
|
|
233
|
+
toast(
|
|
234
|
+
t('collections.edit.copyToLocaleTitle', { label }),
|
|
235
|
+
t('collections.edit.copiedSuccessMessage', {
|
|
236
|
+
source: sourceLabel,
|
|
237
|
+
target: targetLabel,
|
|
238
|
+
}),
|
|
239
|
+
'success'
|
|
240
|
+
)
|
|
241
|
+
}
|
|
223
242
|
notifyScheduleSuspended()
|
|
224
243
|
navigate({
|
|
225
244
|
to: getAdminRoutePath('singletons', '$singleton'),
|
|
@@ -14,6 +14,10 @@ import type { CopyToLocaleResult, DocumentLifecycleContext } from '@byline/core/
|
|
|
14
14
|
import { copyToLocale } from '@byline/core/services'
|
|
15
15
|
|
|
16
16
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
17
|
+
import {
|
|
18
|
+
type CollectionDocumentCommittedHookFailureResponse,
|
|
19
|
+
toCommittedDocumentHookFailureResponse,
|
|
20
|
+
} from './save-outcome.js'
|
|
17
21
|
|
|
18
22
|
// ---------------------------------------------------------------------------
|
|
19
23
|
// Copy document content from one locale into another
|
|
@@ -38,34 +42,44 @@ export const copyDocumentToLocale = createServerFn({ method: 'POST' })
|
|
|
38
42
|
overwrite: boolean
|
|
39
43
|
}) => input
|
|
40
44
|
)
|
|
41
|
-
.handler(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
.handler(
|
|
46
|
+
async ({
|
|
47
|
+
data: input,
|
|
48
|
+
}): Promise<CopyToLocaleResult | CollectionDocumentCommittedHookFailureResponse> => {
|
|
49
|
+
const { collection: path, id, sourceLocale, targetLocale, overwrite } = input
|
|
50
|
+
const logger = getLogger()
|
|
51
|
+
const config = await ensureCollection(path)
|
|
52
|
+
if (!config) {
|
|
53
|
+
throw ERR_NOT_FOUND({
|
|
54
|
+
message: 'Collection not found',
|
|
55
|
+
details: { collectionPath: path },
|
|
56
|
+
}).log(logger)
|
|
57
|
+
}
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
const serverConfig = getServerConfig()
|
|
60
|
+
const ctx: DocumentLifecycleContext = {
|
|
61
|
+
db: serverConfig.db,
|
|
62
|
+
definition: config.definition,
|
|
63
|
+
collectionId: config.collection.id,
|
|
64
|
+
collectionVersion: config.collection.version,
|
|
65
|
+
collectionPath: path,
|
|
66
|
+
logger,
|
|
67
|
+
defaultLocale: serverConfig.i18n.content.defaultLocale,
|
|
68
|
+
slugifier: serverConfig.slugifier,
|
|
69
|
+
requestContext: await getAdminRequestContext(),
|
|
70
|
+
}
|
|
64
71
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
+
try {
|
|
73
|
+
return await copyToLocale(ctx, {
|
|
74
|
+
documentId: id,
|
|
75
|
+
sourceLocale,
|
|
76
|
+
targetLocale,
|
|
77
|
+
overwrite,
|
|
78
|
+
})
|
|
79
|
+
} catch (error) {
|
|
80
|
+
const committedFailure = toCommittedDocumentHookFailureResponse(error)
|
|
81
|
+
if (committedFailure != null) return committedFailure
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
)
|