@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
@@ -29,28 +29,29 @@ import {
29
29
  deleteDocument,
30
30
  deleteDocumentLocale,
31
31
  duplicateCollectionDocument,
32
+ hasCommittedDocumentHookFailure,
32
33
  hasDeleteSideEffectFailures,
33
34
  scheduleCollectionDocumentPublish,
34
35
  unpublishDocument,
35
- updateCollectionDocumentSystemFields,
36
36
  updateCollectionDocumentWithPatches,
37
37
  updateDocumentStatus,
38
38
  } from '../../server-fns/collections/index.js'
39
39
  import { useNavigate } from '../chrome/loose-router.js'
40
+ import { useDocumentMutationState } from '../document-mutation-state.js'
40
41
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
41
42
  import { ViewMenu } from './view-menu.js'
42
43
  import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
43
44
  import type { ContentLocaleOption } from './view-menu.js'
44
45
 
45
46
  type EditState = {
46
- status: 'success' | 'failed' | 'busy' | 'idle'
47
+ status: 'success' | 'warning' | 'failed' | 'busy' | 'idle'
47
48
  message: string
48
49
  }
49
50
 
50
51
  export const EditView = ({
51
52
  collectionDefinition,
52
53
  adminConfig,
53
- initialData,
54
+ initialData: loadedData,
54
55
  locale,
55
56
  contentLocales,
56
57
  defaultContentLocale,
@@ -65,6 +66,8 @@ export const EditView = ({
65
66
  /** URL-encoded list search state to return to on close — see list-return-state.ts. */
66
67
  returnSearch?: Record<string, unknown>
67
68
  }) => {
69
+ const mutation = useDocumentMutationState(loadedData)
70
+ const initialData = mutation.document ?? loadedData
68
71
  const toastManager = useToastManager()
69
72
  const { t } = useTranslation('byline-admin')
70
73
  const [_editState, setEditState] = useState<EditState>({
@@ -110,6 +113,16 @@ export const EditView = ({
110
113
  })
111
114
  }
112
115
 
116
+ const notifyCommittedHookFailure = () => {
117
+ const description = t('collections.save.hookFailedDescription')
118
+ toastManager.add({
119
+ title: t('collections.save.hookFailedToast'),
120
+ description,
121
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
122
+ })
123
+ setEditState({ status: 'warning', message: description })
124
+ }
125
+
113
126
  const handleLocaleChange = (newLocale: string) => {
114
127
  navigate({
115
128
  to: getAdminRoutePath('collections', '$collection', '$id'),
@@ -120,9 +133,16 @@ export const EditView = ({
120
133
 
121
134
  const handleStatusChange = async (status: string) => {
122
135
  try {
123
- await updateDocumentStatus({
124
- data: { collection: path, id: String(initialData.id), status },
136
+ mutation.assertWritable()
137
+ const result = await updateDocumentStatus({
138
+ data: {
139
+ expectedRevision: mutation.expectedRevision(),
140
+ collection: path,
141
+ id: String(initialData.id),
142
+ status,
143
+ },
125
144
  })
145
+ mutation.adopt(result)
126
146
  const description = t('collections.edit.statusChangedDescription', { status })
127
147
  toastManager.add({
128
148
  title: t('collections.edit.statusUpdateTitle', { label: singular }),
@@ -142,9 +162,16 @@ export const EditView = ({
142
162
  search: (prev: Record<string, unknown>) => ({ ...prev }),
143
163
  })
144
164
  } catch (err) {
165
+ const handled = mutation.report(err)
166
+ if (handled === 'blocked') throw err
167
+ if (handled === 'committed') {
168
+ notifyCommittedHookFailure()
169
+ reloadDocument()
170
+ return
171
+ }
145
172
  console.error('Status change error:', err)
146
173
  const description = t('collections.edit.statusChangeFailedDescription', {
147
- message: (err as Error).message,
174
+ message: t('documentConcurrency.failed'),
148
175
  })
149
176
  toastManager.add({
150
177
  title: t('collections.edit.statusUpdateTitle', { label: singular }),
@@ -170,14 +197,17 @@ export const EditView = ({
170
197
 
171
198
  const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
172
199
  try {
173
- await scheduleCollectionDocumentPublish({
200
+ mutation.assertWritable()
201
+ const result = await scheduleCollectionDocumentPublish({
174
202
  data: {
203
+ expectedRevision: mutation.expectedRevision(),
175
204
  collection: path,
176
205
  id: String(initialData.id),
177
206
  publishAt,
178
207
  expectedVersionId: String(initialData.versionId),
179
208
  },
180
209
  })
210
+ mutation.adopt(result)
181
211
  toastManager.add({
182
212
  title: t('scheduledPublication.toast.scheduledTitle'),
183
213
  description: t('scheduledPublication.toast.scheduledDescription'),
@@ -185,9 +215,16 @@ export const EditView = ({
185
215
  })
186
216
  reloadDocument()
187
217
  } catch (err) {
218
+ const handled = mutation.report(err)
219
+ if (handled === 'blocked') throw err
220
+ if (handled === 'committed') {
221
+ notifyCommittedHookFailure()
222
+ reloadDocument()
223
+ return
224
+ }
188
225
  toastManager.add({
189
226
  title: t('scheduledPublication.toast.failedTitle'),
190
- description: (err as Error).message,
227
+ description: t('documentConcurrency.failed'),
191
228
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
192
229
  })
193
230
  throw err
@@ -196,13 +233,16 @@ export const EditView = ({
196
233
 
197
234
  const handleConfirmScheduledPublication = async () => {
198
235
  try {
199
- await confirmCollectionDocumentScheduledPublish({
236
+ mutation.assertWritable()
237
+ const result = await confirmCollectionDocumentScheduledPublish({
200
238
  data: {
239
+ expectedRevision: mutation.expectedRevision(),
201
240
  collection: path,
202
241
  id: String(initialData.id),
203
242
  expectedVersionId: String(initialData.versionId),
204
243
  },
205
244
  })
245
+ mutation.adopt(result)
206
246
  toastManager.add({
207
247
  title: t('scheduledPublication.toast.confirmedTitle'),
208
248
  description: t('scheduledPublication.toast.confirmedDescription'),
@@ -210,9 +250,16 @@ export const EditView = ({
210
250
  })
211
251
  reloadDocument()
212
252
  } catch (err) {
253
+ const handled = mutation.report(err)
254
+ if (handled === 'blocked') throw err
255
+ if (handled === 'committed') {
256
+ notifyCommittedHookFailure()
257
+ reloadDocument()
258
+ return
259
+ }
213
260
  toastManager.add({
214
261
  title: t('scheduledPublication.toast.failedTitle'),
215
- description: (err as Error).message,
262
+ description: t('documentConcurrency.failed'),
216
263
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
217
264
  })
218
265
  throw err
@@ -221,9 +268,15 @@ export const EditView = ({
221
268
 
222
269
  const handleCancelScheduledPublication = async () => {
223
270
  try {
271
+ mutation.assertWritable()
224
272
  const result = await cancelCollectionDocumentScheduledPublish({
225
- data: { collection: path, id: String(initialData.id) },
273
+ data: {
274
+ expectedRevision: mutation.expectedRevision(),
275
+ collection: path,
276
+ id: String(initialData.id),
277
+ },
226
278
  })
279
+ mutation.adopt({ ...result, documentId: String(initialData.id) })
227
280
  const cancelled = result.status === 'cancelled'
228
281
  toastManager.add({
229
282
  title: cancelled
@@ -241,9 +294,16 @@ export const EditView = ({
241
294
  })
242
295
  reloadDocument()
243
296
  } catch (err) {
297
+ const handled = mutation.report(err)
298
+ if (handled === 'blocked') throw err
299
+ if (handled === 'committed') {
300
+ notifyCommittedHookFailure()
301
+ reloadDocument()
302
+ return
303
+ }
244
304
  toastManager.add({
245
305
  title: t('scheduledPublication.toast.failedTitle'),
246
- description: (err as Error).message,
306
+ description: t('documentConcurrency.failed'),
247
307
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
248
308
  })
249
309
  throw err
@@ -264,7 +324,15 @@ export const EditView = ({
264
324
 
265
325
  const handleUnpublish = async () => {
266
326
  try {
267
- await unpublishDocument({ data: { collection: path, id: String(initialData.id) } })
327
+ mutation.assertWritable()
328
+ const result = await unpublishDocument({
329
+ data: {
330
+ expectedRevision: mutation.expectedRevision(),
331
+ collection: path,
332
+ id: String(initialData.id),
333
+ },
334
+ })
335
+ mutation.adopt(result)
268
336
  const description = t('collections.edit.unpublishedDescription')
269
337
  toastManager.add({
270
338
  title: t('collections.edit.unpublishTitle', { label: singular }),
@@ -283,9 +351,16 @@ export const EditView = ({
283
351
  search: (prev: Record<string, unknown>) => ({ ...prev }),
284
352
  })
285
353
  } catch (err) {
354
+ const handled = mutation.report(err)
355
+ if (handled === 'blocked') throw err
356
+ if (handled === 'committed') {
357
+ notifyCommittedHookFailure()
358
+ reloadDocument()
359
+ return
360
+ }
286
361
  console.error('Unpublish error:', err)
287
362
  const description = t('collections.edit.unpublishFailedDescription', {
288
- message: (err as Error).message,
363
+ message: t('documentConcurrency.failed'),
289
364
  })
290
365
  toastManager.add({
291
366
  title: t('collections.edit.unpublishTitle', { label: singular }),
@@ -303,9 +378,23 @@ export const EditView = ({
303
378
 
304
379
  const handleDuplicate = async () => {
305
380
  try {
381
+ mutation.assertWritable()
306
382
  const result = await duplicateCollectionDocument({
307
- data: { collection: path, id: String(initialData.id) },
383
+ data: {
384
+ expectedRevision: mutation.expectedRevision(),
385
+ collection: path,
386
+ id: String(initialData.id),
387
+ },
308
388
  })
389
+ if (hasCommittedDocumentHookFailure(result)) {
390
+ notifyCommittedHookFailure()
391
+ navigate({
392
+ to: getAdminRoutePath('collections', '$collection', '$id'),
393
+ params: { collection: path, id: result.documentId },
394
+ search: (prev: Record<string, unknown>) => ({ ...prev }),
395
+ })
396
+ return
397
+ }
309
398
  const description = result.pathRetried
310
399
  ? t('collections.edit.duplicatedAutoPathDescription', { path: result.newPath })
311
400
  : t('collections.edit.duplicatedPathDescription', { path: result.newPath })
@@ -332,9 +421,16 @@ export const EditView = ({
332
421
  search: (prev: Record<string, unknown>) => ({ ...prev }),
333
422
  })
334
423
  } catch (err) {
424
+ const handled = mutation.report(err)
425
+ if (handled === 'blocked') throw err
426
+ if (handled === 'committed') {
427
+ notifyCommittedHookFailure()
428
+ reloadDocument()
429
+ return
430
+ }
335
431
  console.error('Duplicate error:', err)
336
432
  const description = t('collections.edit.duplicateFailedDescription', {
337
- message: (err as Error).message,
433
+ message: t('documentConcurrency.failed'),
338
434
  })
339
435
  toastManager.add({
340
436
  title: t('collections.edit.duplicateTitle', { label: singular }),
@@ -358,8 +454,10 @@ export const EditView = ({
358
454
  overwrite: boolean
359
455
  }) => {
360
456
  try {
457
+ mutation.assertWritable()
361
458
  const result = await copyDocumentToLocale({
362
459
  data: {
460
+ expectedRevision: mutation.expectedRevision(),
363
461
  collection: path,
364
462
  id: String(initialData.id),
365
463
  sourceLocale: locale ?? defaultContentLocale,
@@ -367,6 +465,17 @@ export const EditView = ({
367
465
  overwrite,
368
466
  },
369
467
  })
468
+ mutation.adopt(result)
469
+ if (hasCommittedDocumentHookFailure(result)) {
470
+ notifyCommittedHookFailure()
471
+ notifyScheduleSuspended()
472
+ navigate({
473
+ to: getAdminRoutePath('collections', '$collection', '$id'),
474
+ params: { collection: path, id: String(initialData.id) },
475
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: targetLocale }),
476
+ })
477
+ return
478
+ }
370
479
  const sourceLabel =
371
480
  contentLocales.find((l) => l.code === result.sourceLocale)?.label ?? result.sourceLocale
372
481
  const targetLabel =
@@ -408,9 +517,16 @@ export const EditView = ({
408
517
  search: (prev: Record<string, unknown>) => ({ ...prev, locale: targetLocale }),
409
518
  })
410
519
  } catch (err) {
520
+ const handled = mutation.report(err)
521
+ if (handled === 'blocked') throw err
522
+ if (handled === 'committed') {
523
+ notifyCommittedHookFailure()
524
+ reloadDocument()
525
+ return
526
+ }
411
527
  console.error('Copy to locale error:', err)
412
528
  const description = t('collections.edit.copyFailedDescription', {
413
- message: (err as Error).message,
529
+ message: t('documentConcurrency.failed'),
414
530
  })
415
531
  toastManager.add({
416
532
  title: t('collections.edit.copyToLocaleTitle', { label: singular }),
@@ -428,13 +544,26 @@ export const EditView = ({
428
544
 
429
545
  const handleDeleteLocale = async ({ targetLocale }: { targetLocale: string }) => {
430
546
  try {
547
+ mutation.assertWritable()
431
548
  const result = await deleteDocumentLocale({
432
549
  data: {
550
+ expectedRevision: mutation.expectedRevision(),
433
551
  collection: path,
434
552
  id: String(initialData.id),
435
553
  locale: targetLocale,
436
554
  },
437
555
  })
556
+ mutation.adopt(result)
557
+ if (hasCommittedDocumentHookFailure(result)) {
558
+ notifyCommittedHookFailure()
559
+ notifyScheduleSuspended()
560
+ navigate({
561
+ to: getAdminRoutePath('collections', '$collection', '$id'),
562
+ params: { collection: path, id: String(initialData.id) },
563
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: defaultContentLocale }),
564
+ })
565
+ return
566
+ }
438
567
  const targetLabel =
439
568
  contentLocales.find((l) => l.code === result.locale)?.label ?? result.locale
440
569
  const description = t('collections.edit.deletedLocaleDescription', {
@@ -461,9 +590,16 @@ export const EditView = ({
461
590
  search: (prev: Record<string, unknown>) => ({ ...prev, locale: defaultContentLocale }),
462
591
  })
463
592
  } catch (err) {
593
+ const handled = mutation.report(err)
594
+ if (handled === 'blocked') throw err
595
+ if (handled === 'committed') {
596
+ notifyCommittedHookFailure()
597
+ reloadDocument()
598
+ return
599
+ }
464
600
  console.error('Delete locale error:', err)
465
601
  const description = t('collections.edit.deleteLocaleFailedDescription', {
466
- message: (err as Error).message,
602
+ message: t('documentConcurrency.failed'),
467
603
  })
468
604
  toastManager.add({
469
605
  title: t('collections.edit.deleteLocaleTitle', { label: singular }),
@@ -481,9 +617,28 @@ export const EditView = ({
481
617
 
482
618
  const handleDelete = async () => {
483
619
  try {
620
+ mutation.assertWritable()
484
621
  const result = await deleteDocument({
485
- data: { collection: path, id: String(initialData.id) },
622
+ data: {
623
+ expectedRevision: mutation.expectedRevision(),
624
+ collection: path,
625
+ id: String(initialData.id),
626
+ },
486
627
  })
628
+ if (result.scheduledPublicationsNeedReconfirmation) {
629
+ toastManager.add({
630
+ title: t('documentConcurrency.schedulesTitle'),
631
+ description: (
632
+ <>
633
+ <p>{t('documentConcurrency.schedules')}</p>
634
+ <a href={getAdminRoutePath('scheduled-publications')}>
635
+ {t('documentConcurrency.reviewSchedules')}
636
+ </a>
637
+ </>
638
+ ),
639
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
640
+ })
641
+ }
487
642
  const hasSideEffectFailures = hasDeleteSideEffectFailures(result)
488
643
  const description = hasSideEffectFailures
489
644
  ? t('collections.edit.deletedWithWarningsDescription', { label: singular })
@@ -498,7 +653,10 @@ export const EditView = ({
498
653
  close: true,
499
654
  },
500
655
  })
501
- setEditState({ status: 'success', message: description })
656
+ setEditState({
657
+ status: hasSideEffectFailures ? 'warning' : 'success',
658
+ message: description,
659
+ })
502
660
  // Navigate back to the collection list after deletion. The return
503
661
  // target has now been consumed — clear the stored fallback so a later
504
662
  // visit doesn't resurrect a stale list position.
@@ -509,9 +667,16 @@ export const EditView = ({
509
667
  search: returnSearch,
510
668
  })
511
669
  } catch (err) {
670
+ const handled = mutation.report(err)
671
+ if (handled === 'blocked') throw err
672
+ if (handled === 'committed') {
673
+ notifyCommittedHookFailure()
674
+ reloadDocument()
675
+ return
676
+ }
512
677
  console.error('Delete error:', err)
513
678
  const description = t('collections.edit.deleteFailedDescription', {
514
- message: (err as Error).message,
679
+ message: t('documentConcurrency.failed'),
515
680
  })
516
681
  toastManager.add({
517
682
  title: t('collections.edit.deleteTitle', { label: singular }),
@@ -550,65 +715,65 @@ export const EditView = ({
550
715
  systemAvailableLocales?: string[]
551
716
  }) => {
552
717
  try {
553
- // Document-grain system fields write first via their own non-versioned
554
- // path — so a path conflict surfaces before we mint a content version,
555
- // and these immediate writes never reset workflow status. See
556
- // docs/08-internationalization/index.md.
557
- if (pathDirty || availableLocalesDirty) {
558
- await updateCollectionDocumentSystemFields({
559
- data: {
560
- collection: path,
561
- id: String(initialData.id),
562
- locale,
563
- ...(pathDirty ? { path: systemPath ?? null } : {}),
564
- ...(availableLocalesDirty ? { availableLocales: systemAvailableLocales ?? [] } : {}),
565
- },
566
- })
567
- }
568
-
569
- // Content (field data / patches) follows the normal versioned path —
570
- // mints a new draft version. Skipped entirely when only the system
571
- // fields changed, so a path/advertising edit never creates an empty
572
- // content version.
573
- if (contentDirty) {
574
- await updateCollectionDocumentWithPatches({
575
- data: {
576
- collection: path,
577
- id: String(initialData.id),
578
- patches,
579
- versionId: initialData.versionId as string | undefined,
580
- locale,
581
- },
582
- })
583
- }
584
-
585
- if (contentDirty && scheduledPublication?.state === 'armed') {
718
+ mutation.assertWritable()
719
+ const result = await updateCollectionDocumentWithPatches({
720
+ data: {
721
+ collection: path,
722
+ id: String(initialData.id),
723
+ expectedRevision: mutation.expectedRevision(),
724
+ patches: contentDirty ? patches : [],
725
+ locale,
726
+ ...(pathDirty ? { path: systemPath ?? null } : {}),
727
+ ...(availableLocalesDirty ? { availableLocales: systemAvailableLocales ?? [] } : {}),
728
+ },
729
+ })
730
+ mutation.adopt(result)
731
+ const hookFailed = hasCommittedDocumentHookFailure(result)
732
+ if (
733
+ (contentDirty || pathDirty || availableLocalesDirty) &&
734
+ scheduledPublication?.state === 'armed'
735
+ ) {
586
736
  notifyScheduleSuspended()
587
737
  }
588
738
 
589
- const description = t('collections.edit.updatedDescription', { label: singularLower })
739
+ const description = hookFailed
740
+ ? t('collections.save.hookFailedDescription')
741
+ : t('collections.edit.updatedDescription', { label: singularLower })
590
742
  toastManager.add({
591
- title: t('collections.edit.updateTitle', { label: singular }),
743
+ title: hookFailed
744
+ ? t('collections.save.hookFailedToast')
745
+ : t('collections.edit.updateTitle', { label: singular }),
592
746
  description,
593
747
  data: {
594
- intent: 'success',
595
- iconType: 'success',
748
+ intent: hookFailed ? 'warning' : 'success',
749
+ iconType: hookFailed ? 'warning' : 'success',
596
750
  icon: true,
597
751
  close: true,
598
752
  },
599
753
  })
600
754
 
601
- setEditState({ status: 'success', message: description })
755
+ setEditState({ status: hookFailed ? 'warning' : 'success', message: description })
602
756
 
603
757
  // Re-navigate to the same route so the loader re-fetches the document.
604
- // The new version will have a fresh version ID, draft status, and
605
- // updated publishedVersion metadata.
758
+ // The new version will have a fresh version ID, the collection's default
759
+ // workflow status, and updated publishedVersion metadata.
606
760
  navigate({
607
761
  to: getAdminRoutePath('collections', '$collection', '$id'),
608
762
  params: { collection: path, id: String(initialData.id) },
609
763
  search: (prev: Record<string, unknown>) => ({ ...prev }),
764
+ // Every write above has succeeded. FormRenderer clears its dirty
765
+ // baseline after this handler returns, so bypass the guard during the
766
+ // intentional post-save refresh rather than racing that state update.
767
+ ignoreBlocker: true,
610
768
  })
611
769
  } catch (err) {
770
+ const handled = mutation.report(err)
771
+ if (handled === 'blocked') throw err
772
+ if (handled === 'committed') {
773
+ notifyCommittedHookFailure()
774
+ reloadDocument()
775
+ return
776
+ }
612
777
  console.error('Network error:', err)
613
778
  const description = t('collections.edit.updateFailedDescription', { label: singularLower })
614
779
  toastManager.add({
@@ -634,6 +799,13 @@ export const EditView = ({
634
799
  <Section>
635
800
  <Container>
636
801
  <FormRenderer
802
+ mutationIssue={mutation.issue}
803
+ mutationsBlocked={mutation.blocked}
804
+ observedRevision={mutation.revision}
805
+ onMutationError={mutation.report}
806
+ onTreeMutationCommitted={mutation.adopt}
807
+ scheduledPublicationsNeedReconfirmation={mutation.scheduleNotice}
808
+ scheduledPublicationsHref={getAdminRoutePath('scheduled-publications')}
637
809
  mode="edit"
638
810
  fields={fields}
639
811
  onSubmit={handleSubmit}
@@ -155,6 +155,7 @@ export const HistoryView = ({
155
155
  currentVersionId,
156
156
  restoreStatusLabel,
157
157
  openCompare,
158
+ mutationsBlocked,
158
159
  openRestore,
159
160
  }) =>
160
161
  columns.flatMap((column) => {
@@ -213,6 +214,7 @@ export const HistoryView = ({
213
214
  variant="outlined"
214
215
  size="xs"
215
216
  intent="noeffect"
217
+ disabled={mutationsBlocked}
216
218
  onClick={openRestore}
217
219
  className={cx('byline-coll-history-restore-button', styles.restoreButton)}
218
220
  title={t('collections.history.restoreButtonTitle', {
@@ -279,8 +281,15 @@ export const HistoryView = ({
279
281
  rowPresentation={rowPresentation}
280
282
  loadHistoricalVersion={getCollectionDocumentVersion}
281
283
  onPageSizeChange={handlePageSizeChange}
282
- restoreVersion={(versionId) =>
283
- restoreDocumentVersion({ data: { collection, id, versionId } })
284
+ restoreVersion={(versionId, expectedRevision) =>
285
+ restoreDocumentVersion({
286
+ data: {
287
+ expectedRevision,
288
+ collection,
289
+ id,
290
+ versionId,
291
+ },
292
+ })
284
293
  }
285
294
  onRestoreComplete={async () => {
286
295
  await router.invalidate()
@@ -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
@@ -178,6 +180,7 @@ export const ListView = ({
178
180
  const navigate = useNavigate()
179
181
  const router = useRouter()
180
182
  const toastManager = useToastManager()
183
+ const mutation = useDocumentMutationState(undefined)
181
184
  const { t } = useTranslation('byline-admin')
182
185
  const location = useRouterState({ select: (s) => s.location })
183
186
 
@@ -191,10 +194,10 @@ export const ListView = ({
191
194
  const [localDocs, setLocalDocs] = useState(data.docs)
192
195
  const [isReordering, setIsReordering] = useState(false)
193
196
  useEffect(() => {
194
- if (!isReordering) {
197
+ if (!isReordering && !mutation.blocked) {
195
198
  setLocalDocs(data.docs)
196
199
  }
197
- }, [data.docs, isReordering])
200
+ }, [data.docs, isReordering, mutation.blocked])
198
201
 
199
202
  // Drag is only meaningful in the canonical view: the default order_key
200
203
  // sort, no search, no status filter. Otherwise the visible order isn't
@@ -209,7 +212,7 @@ export const ListView = ({
209
212
  }
210
213
  const isCanonicalView =
211
214
  !searchParams.order && !searchParams.desc && !searchParams.query && !searchParams.status
212
- const dragEnabled = orderable && isCanonicalView && !!onReorder
215
+ const dragEnabled = orderable && isCanonicalView && !!onReorder && !mutation.blocked
213
216
 
214
217
  // The *effective* sort for the header indicators: explicit URL params win;
215
218
  // otherwise the server echoes a configured `defaultSort` (admin config)
@@ -226,7 +229,7 @@ export const ListView = ({
226
229
 
227
230
  const handleDragEnd = async (event: DragEndEvent) => {
228
231
  const { active, over } = event
229
- if (!over || active.id === over.id || !onReorder) return
232
+ if (mutation.blocked || isReordering || !over || active.id === over.id || !onReorder) return
230
233
  const oldIndex = localDocs.findIndex((d) => d.id === active.id)
231
234
  const newIndex = localDocs.findIndex((d) => d.id === over.id)
232
235
  if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
@@ -242,14 +245,26 @@ export const ListView = ({
242
245
  const before = next[newIndex - 1]?.id ?? null
243
246
  const after = next[newIndex + 1]?.id ?? null
244
247
  try {
245
- await onReorder({
248
+ mutation.assertWritable()
249
+ const receipt = await onReorder({
246
250
  documentId: String(active.id),
247
251
  beforeDocumentId: before,
248
252
  afterDocumentId: after,
249
253
  })
250
- await router.invalidate()
254
+ mutation.reportStructure(receipt)
255
+ try {
256
+ await router.invalidate()
257
+ } catch {
258
+ // The mutation committed; a failed refresh must not undo its UI receipt.
259
+ toastManager.add({
260
+ title: t('collections.list.treeRefreshFailedToast'),
261
+ description: t('collections.list.treeRefreshFailedDescription'),
262
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
263
+ })
264
+ }
251
265
  } catch (_err) {
252
266
  setLocalDocs(previousDocs)
267
+ if (mutation.report(_err)) return
253
268
  toastManager.add({
254
269
  title: t('collections.list.reorderFailedToast'),
255
270
  description: t('collections.list.reorderFailedDescription'),
@@ -359,6 +374,7 @@ export const ListView = ({
359
374
  return (
360
375
  <Section>
361
376
  <Container>
377
+ <DocumentMutationNotice issue={mutation.issue} scheduleNotice={mutation.scheduleNotice} />
362
378
  <div className={cx('byline-coll-list-head', styles.head)}>
363
379
  <h1 className={cx('byline-coll-list-title', styles.title)}>
364
380
  {data.included.collection.labels.plural as string}