@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.
Files changed (45) hide show
  1. package/dist/admin-shell/collections/create.js +22 -5
  2. package/dist/admin-shell/collections/edit.js +85 -17
  3. package/dist/admin-shell/collections/restore-version-modal.js +14 -2
  4. package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
  5. package/dist/admin-shell/singletons/view.js +10 -4
  6. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -1
  7. package/dist/server-fns/collections/copy-to-locale.js +13 -6
  8. package/dist/server-fns/collections/create.d.ts +1 -1
  9. package/dist/server-fns/collections/create.js +15 -7
  10. package/dist/server-fns/collections/delete-locale.d.ts +2 -1
  11. package/dist/server-fns/collections/delete-locale.js +11 -4
  12. package/dist/server-fns/collections/duplicate.d.ts +2 -1
  13. package/dist/server-fns/collections/duplicate.js +10 -3
  14. package/dist/server-fns/collections/index.d.ts +1 -0
  15. package/dist/server-fns/collections/index.js +1 -0
  16. package/dist/server-fns/collections/restore-version.d.ts +1 -1
  17. package/dist/server-fns/collections/restore-version.js +17 -10
  18. package/dist/server-fns/collections/save-outcome.d.ts +23 -0
  19. package/dist/server-fns/collections/save-outcome.js +18 -0
  20. package/dist/server-fns/collections/update.d.ts +3 -1
  21. package/dist/server-fns/collections/update.js +17 -7
  22. package/dist/server-fns/singletons/copy-to-locale.js +14 -5
  23. package/dist/server-fns/singletons/restore-version.js +10 -1
  24. package/dist/server-fns/singletons/update.js +12 -5
  25. package/package.json +10 -10
  26. package/src/admin-shell/collections/create.tsx +22 -3
  27. package/src/admin-shell/collections/edit.tsx +63 -10
  28. package/src/admin-shell/collections/restore-version-modal.tsx +11 -2
  29. package/src/admin-shell/collections/save-navigation.test.tsx +384 -0
  30. package/src/admin-shell/singletons/history.test.tsx +43 -0
  31. package/src/admin-shell/singletons/view.test.tsx +27 -0
  32. package/src/admin-shell/singletons/view.tsx +35 -16
  33. package/src/server-fns/collections/copy-to-locale.ts +43 -29
  34. package/src/server-fns/collections/create.ts +16 -8
  35. package/src/server-fns/collections/delete-locale.ts +38 -24
  36. package/src/server-fns/collections/duplicate.ts +38 -24
  37. package/src/server-fns/collections/index.ts +1 -0
  38. package/src/server-fns/collections/restore-version.ts +16 -9
  39. package/src/server-fns/collections/save-outcome.test.node.ts +77 -0
  40. package/src/server-fns/collections/save-outcome.ts +55 -0
  41. package/src/server-fns/collections/update.ts +23 -8
  42. package/src/server-fns/singletons/copy-to-locale.ts +16 -9
  43. package/src/server-fns/singletons/restore-version.ts +12 -3
  44. package/src/server-fns/singletons/singletons.test.node.ts +34 -0
  45. 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
- toast(
134
- t('singletons.edit.updateTitle', { label }),
135
- t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
136
- 'success'
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
- toast(
216
- t('collections.edit.copyToLocaleTitle', { label }),
217
- t('collections.edit.copiedSuccessMessage', {
218
- source: sourceLabel,
219
- target: targetLabel,
220
- }),
221
- 'success'
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(async ({ data: input }): Promise<CopyToLocaleResult> => {
42
- const { collection: path, id, sourceLocale, targetLocale, overwrite } = input
43
- const logger = getLogger()
44
- const config = await ensureCollection(path)
45
- if (!config) {
46
- throw ERR_NOT_FOUND({
47
- message: 'Collection not found',
48
- details: { collectionPath: path },
49
- }).log(logger)
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
- const serverConfig = getServerConfig()
53
- const ctx: DocumentLifecycleContext = {
54
- db: serverConfig.db,
55
- definition: config.definition,
56
- collectionId: config.collection.id,
57
- collectionVersion: config.collection.version,
58
- collectionPath: path,
59
- logger,
60
- defaultLocale: serverConfig.i18n.content.defaultLocale,
61
- slugifier: serverConfig.slugifier,
62
- requestContext: await getAdminRequestContext(),
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
- return copyToLocale(ctx, {
66
- documentId: id,
67
- sourceLocale,
68
- targetLocale,
69
- overwrite,
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
+ )