@byline/host-tanstack-start 4.18.0 → 5.0.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 (148) hide show
  1. package/dist/admin-shell/collections/create.js +22 -5
  2. package/dist/admin-shell/collections/edit.d.ts +1 -1
  3. package/dist/admin-shell/collections/edit.js +222 -34
  4. package/dist/admin-shell/collections/history.js +4 -2
  5. package/dist/admin-shell/collections/list.js +30 -6
  6. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  7. package/dist/admin-shell/collections/restore-version-modal.js +23 -6
  8. package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
  9. package/dist/admin-shell/collections/tree-list.js +16 -6
  10. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  11. package/dist/admin-shell/collections/version-history.js +11 -2
  12. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  13. package/dist/admin-shell/document-mutation-notice.js +53 -0
  14. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  15. package/dist/admin-shell/document-mutation-state.js +71 -0
  16. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  17. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  18. package/dist/admin-shell/singletons/history.js +4 -2
  19. package/dist/admin-shell/singletons/view.d.ts +1 -1
  20. package/dist/admin-shell/singletons/view.js +108 -23
  21. package/dist/integrations/byline-field-services.js +2 -9
  22. package/dist/routes/create-collection-edit-route.js +1 -1
  23. package/dist/routes/create-collection-list-route.d.ts +0 -7
  24. package/dist/routes/create-collection-list-route.js +7 -9
  25. package/dist/routes/create-singleton-route.d.ts +2 -0
  26. package/dist/routes/create-singleton-route.js +4 -3
  27. package/dist/server-fns/collections/copy-to-locale.d.ts +4 -8
  28. package/dist/server-fns/collections/copy-to-locale.js +17 -8
  29. package/dist/server-fns/collections/create.d.ts +2 -8
  30. package/dist/server-fns/collections/create.js +20 -10
  31. package/dist/server-fns/collections/delete-locale.d.ts +4 -8
  32. package/dist/server-fns/collections/delete-locale.js +15 -6
  33. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  34. package/dist/server-fns/collections/delete-outcome.js +4 -0
  35. package/dist/server-fns/collections/delete.d.ts +2 -7
  36. package/dist/server-fns/collections/delete.js +5 -3
  37. package/dist/server-fns/collections/duplicate.d.ts +4 -8
  38. package/dist/server-fns/collections/duplicate.js +14 -5
  39. package/dist/server-fns/collections/get.d.ts +1 -7
  40. package/dist/server-fns/collections/get.js +5 -4
  41. package/dist/server-fns/collections/index.d.ts +1 -0
  42. package/dist/server-fns/collections/index.js +1 -0
  43. package/dist/server-fns/collections/list.d.ts +0 -7
  44. package/dist/server-fns/collections/list.js +11 -3
  45. package/dist/server-fns/collections/reorder.d.ts +7 -8
  46. package/dist/server-fns/collections/reorder.js +20 -76
  47. package/dist/server-fns/collections/restore-version.d.ts +4 -8
  48. package/dist/server-fns/collections/restore-version.js +22 -12
  49. package/dist/server-fns/collections/save-outcome.d.ts +24 -0
  50. package/dist/server-fns/collections/save-outcome.js +19 -0
  51. package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
  52. package/dist/server-fns/collections/scheduled-publication.js +31 -21
  53. package/dist/server-fns/collections/status.d.ts +8 -7
  54. package/dist/server-fns/collections/status.js +13 -5
  55. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  56. package/dist/server-fns/collections/tree-mutation.js +17 -5
  57. package/dist/server-fns/collections/tree.d.ts +14 -8
  58. package/dist/server-fns/collections/tree.js +21 -17
  59. package/dist/server-fns/collections/update.d.ts +14 -11
  60. package/dist/server-fns/collections/update.js +48 -24
  61. package/dist/server-fns/collections/upload.d.ts +1 -0
  62. package/dist/server-fns/collections/upload.js +3 -2
  63. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  64. package/dist/server-fns/document-mutation-errors.js +50 -0
  65. package/dist/server-fns/editable-revision.d.ts +9 -0
  66. package/dist/server-fns/editable-revision.js +11 -0
  67. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  68. package/dist/server-fns/singleton-document-read.js +7 -3
  69. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
  70. package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
  71. package/dist/server-fns/singletons/change-status.d.ts +2 -7
  72. package/dist/server-fns/singletons/change-status.js +4 -1
  73. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
  74. package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
  75. package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
  76. package/dist/server-fns/singletons/copy-to-locale.js +16 -5
  77. package/dist/server-fns/singletons/get.d.ts +4 -4
  78. package/dist/server-fns/singletons/restore-version.d.ts +2 -7
  79. package/dist/server-fns/singletons/restore-version.js +13 -1
  80. package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
  81. package/dist/server-fns/singletons/schedule-publish.js +4 -2
  82. package/dist/server-fns/singletons/unpublish.d.ts +2 -7
  83. package/dist/server-fns/singletons/unpublish.js +4 -1
  84. package/dist/server-fns/singletons/update.d.ts +4 -11
  85. package/dist/server-fns/singletons/update.js +16 -8
  86. package/dist/server-fns/structural-receipt.d.ts +4 -0
  87. package/dist/server-fns/structural-receipt.js +31 -0
  88. package/package.json +11 -11
  89. package/src/admin-shell/collections/create.tsx +22 -3
  90. package/src/admin-shell/collections/edit.tsx +233 -61
  91. package/src/admin-shell/collections/history.tsx +11 -2
  92. package/src/admin-shell/collections/list.tsx +22 -6
  93. package/src/admin-shell/collections/restore-version-modal.tsx +23 -5
  94. package/src/admin-shell/collections/save-navigation.test.tsx +532 -0
  95. package/src/admin-shell/collections/tree-list.tsx +16 -6
  96. package/src/admin-shell/collections/version-history.tsx +13 -3
  97. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  98. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  99. package/src/admin-shell/document-mutation-state.ts +95 -0
  100. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  101. package/src/admin-shell/singletons/history.test.tsx +46 -1
  102. package/src/admin-shell/singletons/history.tsx +10 -2
  103. package/src/admin-shell/singletons/view.test.tsx +86 -18
  104. package/src/admin-shell/singletons/view.tsx +145 -45
  105. package/src/integrations/byline-field-services.ts +2 -3
  106. package/src/routes/create-collection-edit-route.tsx +1 -0
  107. package/src/routes/create-collection-list-route.tsx +13 -2
  108. package/src/routes/create-singleton-route.test.node.ts +10 -2
  109. package/src/routes/create-singleton-route.tsx +6 -4
  110. package/src/server-fns/collections/copy-to-locale.ts +48 -29
  111. package/src/server-fns/collections/create.ts +56 -44
  112. package/src/server-fns/collections/delete-locale.ts +48 -25
  113. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  114. package/src/server-fns/collections/delete-outcome.ts +27 -16
  115. package/src/server-fns/collections/delete.ts +44 -38
  116. package/src/server-fns/collections/duplicate.ts +45 -25
  117. package/src/server-fns/collections/get.ts +8 -4
  118. package/src/server-fns/collections/index.ts +1 -0
  119. package/src/server-fns/collections/list.ts +8 -3
  120. package/src/server-fns/collections/reorder.ts +32 -124
  121. package/src/server-fns/collections/restore-version.ts +51 -36
  122. package/src/server-fns/collections/revision-input.test.node.ts +108 -0
  123. package/src/server-fns/collections/save-outcome.test.node.ts +80 -0
  124. package/src/server-fns/collections/save-outcome.ts +57 -0
  125. package/src/server-fns/collections/scheduled-publication.ts +138 -111
  126. package/src/server-fns/collections/status.ts +86 -64
  127. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  128. package/src/server-fns/collections/tree-mutation.ts +28 -11
  129. package/src/server-fns/collections/tree.ts +150 -122
  130. package/src/server-fns/collections/update.ts +110 -71
  131. package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
  132. package/src/server-fns/collections/upload.ts +81 -77
  133. package/src/server-fns/document-mutation-errors.ts +67 -0
  134. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  135. package/src/server-fns/editable-revision.test.node.ts +23 -0
  136. package/src/server-fns/editable-revision.ts +18 -0
  137. package/src/server-fns/singleton-document-read.ts +7 -3
  138. package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
  139. package/src/server-fns/singletons/change-status.ts +10 -3
  140. package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
  141. package/src/server-fns/singletons/copy-to-locale.ts +20 -8
  142. package/src/server-fns/singletons/get.ts +3 -3
  143. package/src/server-fns/singletons/restore-version.ts +17 -3
  144. package/src/server-fns/singletons/schedule-publish.ts +18 -7
  145. package/src/server-fns/singletons/singletons.test.node.ts +96 -24
  146. package/src/server-fns/singletons/unpublish.ts +10 -3
  147. package/src/server-fns/singletons/update.ts +21 -9
  148. package/src/server-fns/structural-receipt.ts +41 -0
@@ -19,12 +19,15 @@
19
19
  import { useState } from 'react'
20
20
 
21
21
  import { useTranslation } from '@byline/i18n/react'
22
- import { Alert, Button, LoaderEllipsis, Modal } from '@byline/ui/react'
22
+ import { Alert, Button, LoaderEllipsis, Modal, useToastManager } from '@byline/ui/react'
23
23
  import cx from 'clsx'
24
24
 
25
+ import { hasCommittedDocumentHookFailure } from '../../server-fns/collections/save-outcome.js'
25
26
  import styles from './restore-version-modal.module.css'
26
27
 
27
28
  interface RestoreVersionModalProps {
29
+ disabled?: boolean
30
+ onMutationError?: (error: unknown) => unknown
28
31
  versionLabel: string
29
32
  versionNumber: number
30
33
  restoreStatusLabel: string
@@ -34,6 +37,8 @@ interface RestoreVersionModalProps {
34
37
  }
35
38
 
36
39
  export function RestoreVersionModal({
40
+ disabled = false,
41
+ onMutationError,
37
42
  versionLabel,
38
43
  versionNumber,
39
44
  restoreStatusLabel,
@@ -42,18 +47,31 @@ export function RestoreVersionModal({
42
47
  onRestoreComplete,
43
48
  }: RestoreVersionModalProps) {
44
49
  const { t } = useTranslation('byline-admin')
50
+ const toastManager = useToastManager()
45
51
  const [error, setError] = useState<string | null>(null)
46
52
  const [pending, setPending] = useState(false)
47
53
 
48
54
  async function handleRestore() {
49
- if (pending) return
55
+ if (pending || disabled) return
50
56
  setPending(true)
51
57
  setError(null)
52
58
  try {
53
- await restoreVersion()
59
+ const result = await restoreVersion()
60
+ if (hasCommittedDocumentHookFailure(result)) {
61
+ toastManager.add({
62
+ title: t('collections.save.hookFailedToast'),
63
+ description: t('collections.save.hookFailedDescription'),
64
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
65
+ })
66
+ }
54
67
  onClose()
55
68
  await onRestoreComplete()
56
69
  } catch (err) {
70
+ if (onMutationError?.(err)) {
71
+ setPending(false)
72
+ onClose()
73
+ return
74
+ }
57
75
  const code = getErrorCode(err)
58
76
  if (code === 'ERR_INVALID_TRANSITION') {
59
77
  setError(t('collections.restore.errors.alreadyCurrent'))
@@ -104,7 +122,7 @@ export function RestoreVersionModal({
104
122
  intent="noeffect"
105
123
  size="sm"
106
124
  onClick={onClose}
107
- disabled={pending}
125
+ disabled={pending || disabled}
108
126
  className={cx('byline-coll-restore-button', styles.button)}
109
127
  >
110
128
  {t('common.actions.cancel')}
@@ -114,7 +132,7 @@ export function RestoreVersionModal({
114
132
  intent="primary"
115
133
  onClick={handleRestore}
116
134
  style={{ minWidth: '80px' }}
117
- disabled={pending}
135
+ disabled={pending || disabled}
118
136
  className={cx('byline-coll-restore-button', styles.button)}
119
137
  >
120
138
  {pending === true ? (
@@ -0,0 +1,532 @@
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 {
13
+ DEFAULT_WORKFLOW,
14
+ ERR_DOCUMENT_STALE,
15
+ ERR_LOCK_CONFLICT,
16
+ type MultiCollectionDefinition,
17
+ SINGLE_STATUS_WORKFLOW,
18
+ } from '@byline/core'
19
+ import { createRoot, type Root } from 'react-dom/client'
20
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
21
+
22
+ const mocks = vi.hoisted(() => ({
23
+ status: vi.fn(),
24
+ deletion: vi.fn(),
25
+ schedule: vi.fn(),
26
+ confirm: vi.fn(),
27
+ cancelSchedule: vi.fn(),
28
+ unpublish: vi.fn(),
29
+ copyToLocale: vi.fn(),
30
+ create: vi.fn(),
31
+ deleteLocale: vi.fn(),
32
+ duplicate: vi.fn(),
33
+ navigate: vi.fn(),
34
+ formProps: [] as Array<Record<string, unknown>>,
35
+ toastAdd: vi.fn(),
36
+ update: vi.fn(),
37
+ updateSystemFields: vi.fn(),
38
+ }))
39
+
40
+ vi.mock('@byline/admin/react', () => ({
41
+ FormRenderer: (props: Record<string, unknown>) => {
42
+ mocks.formProps.push(props)
43
+ return <div data-testid="form-renderer" />
44
+ },
45
+ }))
46
+
47
+ vi.mock('@byline/i18n/react', () => ({
48
+ useTranslation: () => ({
49
+ t: (key: string) => key,
50
+ }),
51
+ }))
52
+
53
+ vi.mock('@byline/ui/react', () => {
54
+ const Pass = ({ children }: { children?: React.ReactNode }) => <div>{children}</div>
55
+ return {
56
+ Container: Pass,
57
+ Section: Pass,
58
+ useToastManager: () => ({ add: mocks.toastAdd }),
59
+ }
60
+ })
61
+
62
+ vi.mock('../../routes/admin-path.js', () => ({
63
+ getAdminRoutePath: (...parts: string[]) => `/admin/${parts.join('/')}`,
64
+ }))
65
+
66
+ vi.mock('../../routes/list-return-state.js', () => ({
67
+ decodeListReturnState: () => ({ page: 2 }),
68
+ }))
69
+
70
+ vi.mock('../../routes/list-return-storage.js', () => ({
71
+ clearListReturnState: vi.fn(),
72
+ }))
73
+
74
+ vi.mock('../../server-fns/collections/index.js', () => ({
75
+ cancelCollectionDocumentScheduledPublish: mocks.cancelSchedule,
76
+ confirmCollectionDocumentScheduledPublish: mocks.confirm,
77
+ copyDocumentToLocale: mocks.copyToLocale,
78
+ createCollectionDocument: mocks.create,
79
+ deleteDocument: mocks.deletion,
80
+ deleteDocumentLocale: mocks.deleteLocale,
81
+ duplicateCollectionDocument: mocks.duplicate,
82
+ hasCommittedDocumentHookFailure: (result: { status: string }) =>
83
+ result.status === 'committed-hook-failed',
84
+ hasDeleteSideEffectFailures: () => false,
85
+ scheduleCollectionDocumentPublish: mocks.schedule,
86
+ unpublishDocument: mocks.unpublish,
87
+ updateCollectionDocumentSystemFields: mocks.updateSystemFields,
88
+ updateCollectionDocumentWithPatches: mocks.update,
89
+ updateDocumentStatus: mocks.status,
90
+ }))
91
+
92
+ vi.mock('../chrome/loose-router.js', () => ({
93
+ useNavigate: () => mocks.navigate,
94
+ }))
95
+
96
+ vi.mock('./tanstack-navigation-guard.js', () => ({
97
+ useTanStackNavigationGuard: () => ({
98
+ isBlocked: false,
99
+ stay: () => {},
100
+ proceed: () => {},
101
+ }),
102
+ }))
103
+
104
+ vi.mock('./view-menu.js', () => ({
105
+ ViewMenu: () => null,
106
+ }))
107
+
108
+ import { CreateView } from './create.js'
109
+ import { EditView } from './edit.js'
110
+
111
+ const collection = {
112
+ path: 'languages',
113
+ labels: { singular: 'Language', plural: 'Languages' },
114
+ workflow: SINGLE_STATUS_WORKFLOW,
115
+ fields: [{ name: 'name', label: 'Name', type: 'text' }],
116
+ } as MultiCollectionDefinition
117
+
118
+ const initialData = {
119
+ id: 'language-en',
120
+ versionId: 'version-1',
121
+ revision: 7,
122
+ status: 'published',
123
+ fields: { name: 'English' },
124
+ }
125
+
126
+ let container: HTMLDivElement
127
+ let root: Root
128
+
129
+ beforeAll(() => {
130
+ ;(
131
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
132
+ ).IS_REACT_ACT_ENVIRONMENT = true
133
+ })
134
+
135
+ beforeEach(() => {
136
+ vi.clearAllMocks()
137
+ mocks.formProps.length = 0
138
+ mocks.create.mockResolvedValue({
139
+ status: 'ok',
140
+ documentId: 'language-th',
141
+ documentVersionId: 'version-created',
142
+ })
143
+ mocks.update.mockResolvedValue({ status: 'ok', documentId: 'language-en', revision: 8 })
144
+ mocks.updateSystemFields.mockResolvedValue({ status: 'ok' })
145
+ mocks.copyToLocale.mockResolvedValue({
146
+ documentId: 'language-en',
147
+ documentVersionId: 'version-2',
148
+ revision: 8,
149
+ sourceLocale: 'en',
150
+ targetLocale: 'th',
151
+ fieldsUpdated: 1,
152
+ })
153
+ mocks.deleteLocale.mockResolvedValue({
154
+ documentId: 'language-en',
155
+ documentVersionId: 'version-2',
156
+ revision: 8,
157
+ locale: 'th',
158
+ })
159
+ mocks.duplicate.mockResolvedValue({
160
+ documentId: 'language-copy',
161
+ documentVersionId: 'version-copy',
162
+ sourceDocumentId: 'language-en',
163
+ newPath: 'english-copy',
164
+ pathRetried: false,
165
+ })
166
+ container = document.createElement('div')
167
+ document.body.appendChild(container)
168
+ root = createRoot(container)
169
+ })
170
+
171
+ afterEach(() => {
172
+ act(() => root.unmount())
173
+ container.remove()
174
+ })
175
+
176
+ afterAll(() => {
177
+ delete (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean })
178
+ .IS_REACT_ACT_ENVIRONMENT
179
+ })
180
+
181
+ function latestSubmit(): (payload: Record<string, unknown>) => Promise<void> {
182
+ const onSubmit = mocks.formProps.at(-1)?.onSubmit
183
+ if (typeof onSubmit !== 'function') throw new Error('FormRenderer onSubmit not found')
184
+ return onSubmit as (payload: Record<string, unknown>) => Promise<void>
185
+ }
186
+
187
+ function latestAction(name: string): (...args: any[]) => Promise<void> {
188
+ const action = mocks.formProps.at(-1)?.[name]
189
+ if (typeof action !== 'function') throw new Error(`FormRenderer ${name} not found`)
190
+ return action as (...args: any[]) => Promise<void>
191
+ }
192
+
193
+ describe('collection post-save navigation', () => {
194
+ it('bypasses the dirty-form blocker when create redirects to the new document', async () => {
195
+ act(() => {
196
+ root.render(<CreateView collectionDefinition={collection} from="page=2" />)
197
+ })
198
+
199
+ await act(async () => {
200
+ await latestSubmit()({ data: { name: 'Thai' } })
201
+ })
202
+
203
+ expect(mocks.navigate).toHaveBeenCalledWith({
204
+ to: '/admin/collections/$collection/$id',
205
+ params: { collection: 'languages', id: 'language-th' },
206
+ search: { action: 'created', from: 'page=2' },
207
+ ignoreBlocker: true,
208
+ })
209
+ })
210
+
211
+ it('bypasses the dirty-form blocker when create falls back to the list', async () => {
212
+ mocks.create.mockResolvedValueOnce({ status: 'ok' })
213
+ act(() => {
214
+ root.render(<CreateView collectionDefinition={collection} from="page=2" />)
215
+ })
216
+
217
+ await act(async () => {
218
+ await latestSubmit()({ data: { name: 'Thai' } })
219
+ })
220
+
221
+ expect(mocks.navigate).toHaveBeenCalledWith({
222
+ to: '/admin/collections/$collection',
223
+ params: { collection: 'languages' },
224
+ search: { page: 2, action: 'created' },
225
+ ignoreBlocker: true,
226
+ })
227
+ })
228
+
229
+ it('does not bypass the blocker or navigate when create fails', async () => {
230
+ const error = new Error('create failed')
231
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
232
+ mocks.create.mockRejectedValueOnce(error)
233
+ act(() => {
234
+ root.render(<CreateView collectionDefinition={collection} />)
235
+ })
236
+
237
+ await expect(latestSubmit()({ data: { name: 'Thai' } })).rejects.toBe(error)
238
+
239
+ expect(mocks.navigate).not.toHaveBeenCalled()
240
+ consoleError.mockRestore()
241
+ })
242
+
243
+ it('treats a committed create hook failure as saved and navigates with a warning', async () => {
244
+ mocks.create.mockResolvedValueOnce({
245
+ status: 'committed-hook-failed',
246
+ documentId: 'language-th',
247
+ documentVersionId: 'version-created',
248
+ sideEffectFailure: { phase: 'afterCreate', code: 'ERR_UNHANDLED' },
249
+ })
250
+ act(() => {
251
+ root.render(<CreateView collectionDefinition={collection} from="page=2" />)
252
+ })
253
+
254
+ await act(async () => {
255
+ await latestSubmit()({ data: { name: 'Thai' } })
256
+ })
257
+
258
+ expect(mocks.toastAdd).toHaveBeenCalledWith({
259
+ title: 'collections.save.hookFailedToast',
260
+ description: 'collections.save.hookFailedDescription',
261
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
262
+ })
263
+ expect(mocks.navigate).toHaveBeenCalledWith({
264
+ to: '/admin/collections/$collection/$id',
265
+ params: { collection: 'languages', id: 'language-th' },
266
+ search: { from: 'page=2' },
267
+ ignoreBlocker: true,
268
+ })
269
+ })
270
+
271
+ it('sends content and metadata in one save using the loaded revision', async () => {
272
+ act(() => {
273
+ root.render(
274
+ <EditView
275
+ collectionDefinition={collection}
276
+ initialData={initialData as never}
277
+ contentLocales={[{ code: 'en', label: 'English' }]}
278
+ defaultContentLocale="en"
279
+ locale="en"
280
+ />
281
+ )
282
+ })
283
+ const patches = [{ kind: 'field.set', path: 'name', value: 'English' }]
284
+ await act(async () => {
285
+ await latestSubmit()({
286
+ data: { name: 'English' },
287
+ patches,
288
+ contentDirty: true,
289
+ pathDirty: true,
290
+ systemPath: 'renamed',
291
+ availableLocalesDirty: true,
292
+ systemAvailableLocales: ['en', 'fr'],
293
+ })
294
+ })
295
+ expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
296
+ data: {
297
+ collection: 'languages',
298
+ id: 'language-en',
299
+ expectedRevision: 7,
300
+ patches,
301
+ locale: 'en',
302
+ path: 'renamed',
303
+ availableLocales: ['en', 'fr'],
304
+ },
305
+ })
306
+ expect(mocks.updateSystemFields).not.toHaveBeenCalled()
307
+ })
308
+
309
+ it('bypasses the dirty-form blocker during the successful edit refresh', async () => {
310
+ act(() => {
311
+ root.render(
312
+ <EditView
313
+ collectionDefinition={collection}
314
+ initialData={initialData as never}
315
+ contentLocales={[{ code: 'en', label: 'English' }]}
316
+ defaultContentLocale="en"
317
+ locale="en"
318
+ />
319
+ )
320
+ })
321
+
322
+ await act(async () => {
323
+ await latestSubmit()({
324
+ data: { name: 'English' },
325
+ patches: [{ kind: 'field.set', path: 'name', value: 'English' }],
326
+ contentDirty: true,
327
+ pathDirty: false,
328
+ availableLocalesDirty: false,
329
+ })
330
+ })
331
+
332
+ expect(mocks.navigate).toHaveBeenCalledWith({
333
+ to: '/admin/collections/$collection/$id',
334
+ params: { collection: 'languages', id: 'language-en' },
335
+ search: expect.any(Function),
336
+ ignoreBlocker: true,
337
+ })
338
+ })
339
+
340
+ it('treats a committed update hook failure as saved and refreshes with a warning', async () => {
341
+ mocks.update.mockResolvedValueOnce({
342
+ status: 'committed-hook-failed',
343
+ documentId: 'language-en',
344
+ documentVersionId: 'version-2',
345
+ revision: 8,
346
+ sideEffectFailure: { phase: 'afterUpdate', code: 'ERR_UNHANDLED' },
347
+ })
348
+ act(() => {
349
+ root.render(
350
+ <EditView
351
+ collectionDefinition={collection}
352
+ initialData={initialData as never}
353
+ contentLocales={[{ code: 'en', label: 'English' }]}
354
+ defaultContentLocale="en"
355
+ locale="en"
356
+ />
357
+ )
358
+ })
359
+
360
+ await act(async () => {
361
+ await latestSubmit()({
362
+ data: { name: 'English' },
363
+ patches: [{ kind: 'field.set', path: 'name', value: 'English' }],
364
+ contentDirty: true,
365
+ pathDirty: false,
366
+ availableLocalesDirty: false,
367
+ })
368
+ })
369
+
370
+ expect(mocks.toastAdd).toHaveBeenCalledWith({
371
+ title: 'collections.save.hookFailedToast',
372
+ description: 'collections.save.hookFailedDescription',
373
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
374
+ })
375
+ expect(mocks.navigate).toHaveBeenCalledWith(expect.objectContaining({ ignoreBlocker: true }))
376
+ })
377
+
378
+ it.each([
379
+ {
380
+ action: 'onDuplicate',
381
+ invoke: () => latestAction('onDuplicate')(),
382
+ mock: mocks.duplicate,
383
+ phase: 'afterCreate',
384
+ expectedSearch: expect.any(Function),
385
+ expectedId: 'language-copy',
386
+ },
387
+ {
388
+ action: 'onCopyToLocale',
389
+ invoke: () => latestAction('onCopyToLocale')({ targetLocale: 'th', overwrite: false }),
390
+ mock: mocks.copyToLocale,
391
+ phase: 'afterUpdate',
392
+ expectedSearch: expect.any(Function),
393
+ expectedId: 'language-en',
394
+ },
395
+ {
396
+ action: 'onDeleteLocale',
397
+ invoke: () => latestAction('onDeleteLocale')({ targetLocale: 'th' }),
398
+ mock: mocks.deleteLocale,
399
+ phase: 'afterUpdate',
400
+ expectedSearch: expect.any(Function),
401
+ expectedId: 'language-en',
402
+ },
403
+ ])('treats committed $action hook failures as completed mutations', async (testCase) => {
404
+ testCase.mock.mockResolvedValueOnce({
405
+ status: 'committed-hook-failed',
406
+ documentId: testCase.expectedId,
407
+ documentVersionId: 'version-committed',
408
+ revision: 8,
409
+ sideEffectFailure: { phase: testCase.phase, code: 'ERR_UNHANDLED' },
410
+ })
411
+ act(() => {
412
+ root.render(
413
+ <EditView
414
+ collectionDefinition={collection}
415
+ initialData={initialData as never}
416
+ contentLocales={[
417
+ { code: 'en', label: 'English' },
418
+ { code: 'th', label: 'Thai' },
419
+ ]}
420
+ defaultContentLocale="en"
421
+ locale="en"
422
+ />
423
+ )
424
+ })
425
+
426
+ await act(testCase.invoke)
427
+
428
+ expect(mocks.toastAdd).toHaveBeenCalledWith({
429
+ title: 'collections.save.hookFailedToast',
430
+ description: 'collections.save.hookFailedDescription',
431
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
432
+ })
433
+ expect(mocks.navigate).toHaveBeenCalledWith({
434
+ to: '/admin/collections/$collection/$id',
435
+ params: { collection: 'languages', id: testCase.expectedId },
436
+ search: testCase.expectedSearch,
437
+ })
438
+ })
439
+ })
440
+
441
+ describe('collection editor mutation lockout', () => {
442
+ const stale = ERR_DOCUMENT_STALE({
443
+ message: 'Test failure',
444
+ details: {
445
+ reason: 'revision_mismatch',
446
+ documentId: 'language-en',
447
+ expectedRevision: 7,
448
+ currentRevision: 8,
449
+ },
450
+ })
451
+ const opened = {
452
+ ...initialData,
453
+ _publishedVersion: initialData,
454
+ status: 'draft',
455
+ _scheduledPublicationEnabled: true,
456
+ _canSchedulePublication: true,
457
+ _scheduledPublish: { state: 'needs_reconfirm' },
458
+ }
459
+ const renderEditor = () =>
460
+ act(() =>
461
+ root.render(
462
+ <EditView
463
+ collectionDefinition={{ ...collection, workflow: DEFAULT_WORKFLOW }}
464
+ initialData={opened as never}
465
+ contentLocales={[
466
+ { code: 'en', label: 'English' },
467
+ { code: 'th', label: 'Thai' },
468
+ ]}
469
+ defaultContentLocale="en"
470
+ locale="en"
471
+ />
472
+ )
473
+ )
474
+ const operations = [
475
+ ['onSubmit', mocks.update, { patches: [], contentDirty: true }],
476
+ ['onStatusChange', mocks.status, 'published'],
477
+ ['onDelete', mocks.deletion, undefined],
478
+ ['onDuplicate', mocks.duplicate, undefined],
479
+ ['onCopyToLocale', mocks.copyToLocale, { targetLocale: 'th', overwrite: true }],
480
+ ['onDeleteLocale', mocks.deleteLocale, { targetLocale: 'th' }],
481
+ ['onUnpublish', mocks.unpublish, undefined],
482
+ ['onSchedulePublication', mocks.schedule, { publishAt: '2030-01-01T00:00:00Z' }],
483
+ ['onConfirmScheduledPublication', mocks.confirm, undefined],
484
+ ['onCancelScheduledPublication', mocks.cancelSchedule, undefined],
485
+ ] as const
486
+ it.each(operations)(
487
+ 'blocks subsequent writes after %s reports stale',
488
+ async (name, operation, argument) => {
489
+ operation.mockRejectedValueOnce(stale)
490
+ renderEditor()
491
+ await act(async () => {
492
+ await expect(latestAction(name)(argument)).rejects.toBe(stale)
493
+ })
494
+ expect(mocks.formProps.at(-1)).toMatchObject({
495
+ mutationIssue: 'stale',
496
+ mutationsBlocked: true,
497
+ observedRevision: 7,
498
+ })
499
+ for (const [otherName, , otherArgument] of operations) {
500
+ await act(async () => {
501
+ await expect(latestAction(otherName)(otherArgument)).rejects.toBe(stale)
502
+ })
503
+ }
504
+ expect(operations.reduce((total, [, mock]) => total + mock.mock.calls.length, 0)).toBe(1)
505
+ expect(mocks.navigate).not.toHaveBeenCalled()
506
+ expect(mocks.toastAdd).not.toHaveBeenCalled()
507
+ }
508
+ )
509
+ it.each([
510
+ ['onSubmit', mocks.update, { patches: [], contentDirty: true }],
511
+ ['onDelete', mocks.deletion, undefined],
512
+ ] as const)(
513
+ 'presents confirmed lock rollback safely for %s',
514
+ async (name, operation, argument) => {
515
+ const failure = ERR_LOCK_CONFLICT({
516
+ message: 'raw InnoDB private SQL',
517
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
518
+ })
519
+ operation.mockRejectedValueOnce(failure)
520
+ renderEditor()
521
+ await act(async () => {
522
+ await expect(latestAction(name)(argument)).rejects.toBe(failure)
523
+ })
524
+ expect(mocks.formProps.at(-1)).toMatchObject({
525
+ mutationIssue: 'lock',
526
+ mutationsBlocked: true,
527
+ observedRevision: 7,
528
+ })
529
+ expect(mocks.toastAdd).not.toHaveBeenCalled()
530
+ }
531
+ )
532
+ })
@@ -1,3 +1,5 @@
1
+ import { DocumentMutationNotice } from '../document-mutation-notice.js'
2
+ import { useDocumentMutationState } from '../document-mutation-state.js'
1
3
  /**
2
4
  * This Source Code is subject to the terms of the Mozilla Public
3
5
  * License, v. 2.0. If a copy of the MPL was not distributed with this
@@ -144,6 +146,7 @@ export const TreeListView = ({
144
146
  /** Header action components (`CollectionAdminConfig.listActions`). */
145
147
  listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>
146
148
  }) => {
149
+ const mutation = useDocumentMutationState(undefined)
147
150
  const { t } = useTranslation('byline-admin')
148
151
  const router = useRouter()
149
152
  const toastManager = useToastManager()
@@ -159,10 +162,14 @@ export const TreeListView = ({
159
162
  const [moveController] = useState(() => createAdminTreeMoveController())
160
163
  const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
161
164
  useEffect(() => {
162
- if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return
165
+ if (
166
+ mutation.blocked ||
167
+ !shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)
168
+ )
169
+ return
163
170
  preserveOptimisticForRows.current = null
164
171
  setLocalPlaced(rows.filter((r) => !r.unplaced))
165
- }, [rows, isMoving])
172
+ }, [rows, isMoving, mutation.blocked])
166
173
 
167
174
  const [activeId, setActiveId] = useState<string | null>(null)
168
175
  const [overId, setOverId] = useState<string | null>(null)
@@ -186,13 +193,13 @@ export const TreeListView = ({
186
193
  }
187
194
 
188
195
  const handleDragStart = ({ active }: DragStartEvent) => {
189
- if (!moveController.isMoving()) setActiveId(String(active.id))
196
+ if (!mutation.blocked && !moveController.isMoving()) setActiveId(String(active.id))
190
197
  }
191
198
  const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
192
199
  const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
193
200
 
194
201
  const handleDragEnd = async ({ active, over }: DragEndEvent) => {
195
- if (moveController.isMoving()) {
202
+ if (mutation.blocked || moveController.isMoving()) {
196
203
  resetDnd()
197
204
  return
198
205
  }
@@ -231,10 +238,12 @@ export const TreeListView = ({
231
238
  return
232
239
  }
233
240
 
241
+ if ('receipt' in outcome) mutation.reportStructure(outcome.receipt)
242
+ const handled = 'error' in outcome ? mutation.report(outcome.error) : null
234
243
  const effects = getAdminTreeMoveUiEffects(outcome)
235
244
  if (effects.rollback) setLocalPlaced(current)
236
245
 
237
- if (effects.structuralFailure != null) {
246
+ if (!handled && effects.structuralFailure != null) {
238
247
  const conflict = effects.structuralFailure === 'conflict'
239
248
  toastManager.add({
240
249
  title: t(
@@ -318,6 +327,7 @@ export const TreeListView = ({
318
327
  return (
319
328
  <Section>
320
329
  <Container>
330
+ <DocumentMutationNotice issue={mutation.issue} scheduleNotice={mutation.scheduleNotice} />
321
331
  <div className={cx('byline-coll-list-head', styles.head)}>
322
332
  <h1 className={cx('byline-coll-list-title', styles.title)}>{collectionLabels.plural}</h1>
323
333
  <span className={cx('byline-coll-list-stats', styles.stats)}>
@@ -380,7 +390,7 @@ export const TreeListView = ({
380
390
  key={row.id}
381
391
  id={row.id}
382
392
  dragging={row.id === activeId}
383
- disabled={isMoving}
393
+ disabled={mutation.blocked || isMoving}
384
394
  dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
385
395
  >
386
396
  {renderCells(row, depth)}