@byline/host-tanstack-start 4.19.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 (142) hide show
  1. package/dist/admin-shell/collections/edit.d.ts +1 -1
  2. package/dist/admin-shell/collections/edit.js +151 -31
  3. package/dist/admin-shell/collections/history.js +4 -2
  4. package/dist/admin-shell/collections/list.js +30 -6
  5. package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
  6. package/dist/admin-shell/collections/restore-version-modal.js +9 -4
  7. package/dist/admin-shell/collections/tree-list.js +16 -6
  8. package/dist/admin-shell/collections/version-history.d.ts +2 -1
  9. package/dist/admin-shell/collections/version-history.js +11 -2
  10. package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
  11. package/dist/admin-shell/document-mutation-notice.js +53 -0
  12. package/dist/admin-shell/document-mutation-state.d.ts +22 -0
  13. package/dist/admin-shell/document-mutation-state.js +71 -0
  14. package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
  15. package/dist/admin-shell/scheduled-publications/list.js +15 -5
  16. package/dist/admin-shell/singletons/history.js +4 -2
  17. package/dist/admin-shell/singletons/view.d.ts +1 -1
  18. package/dist/admin-shell/singletons/view.js +98 -19
  19. package/dist/integrations/byline-field-services.js +2 -9
  20. package/dist/routes/create-collection-edit-route.js +1 -1
  21. package/dist/routes/create-collection-list-route.d.ts +0 -7
  22. package/dist/routes/create-collection-list-route.js +7 -9
  23. package/dist/routes/create-singleton-route.d.ts +2 -0
  24. package/dist/routes/create-singleton-route.js +4 -3
  25. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -7
  26. package/dist/server-fns/collections/copy-to-locale.js +4 -2
  27. package/dist/server-fns/collections/create.d.ts +1 -7
  28. package/dist/server-fns/collections/create.js +5 -3
  29. package/dist/server-fns/collections/delete-locale.d.ts +2 -7
  30. package/dist/server-fns/collections/delete-locale.js +4 -2
  31. package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
  32. package/dist/server-fns/collections/delete-outcome.js +4 -0
  33. package/dist/server-fns/collections/delete.d.ts +2 -7
  34. package/dist/server-fns/collections/delete.js +5 -3
  35. package/dist/server-fns/collections/duplicate.d.ts +2 -7
  36. package/dist/server-fns/collections/duplicate.js +5 -3
  37. package/dist/server-fns/collections/get.d.ts +1 -7
  38. package/dist/server-fns/collections/get.js +5 -4
  39. package/dist/server-fns/collections/list.d.ts +0 -7
  40. package/dist/server-fns/collections/list.js +11 -3
  41. package/dist/server-fns/collections/reorder.d.ts +7 -8
  42. package/dist/server-fns/collections/reorder.js +20 -76
  43. package/dist/server-fns/collections/restore-version.d.ts +3 -7
  44. package/dist/server-fns/collections/restore-version.js +5 -2
  45. package/dist/server-fns/collections/save-outcome.d.ts +1 -0
  46. package/dist/server-fns/collections/save-outcome.js +1 -0
  47. package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
  48. package/dist/server-fns/collections/scheduled-publication.js +31 -21
  49. package/dist/server-fns/collections/status.d.ts +8 -7
  50. package/dist/server-fns/collections/status.js +13 -5
  51. package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
  52. package/dist/server-fns/collections/tree-mutation.js +17 -5
  53. package/dist/server-fns/collections/tree.d.ts +14 -8
  54. package/dist/server-fns/collections/tree.js +21 -17
  55. package/dist/server-fns/collections/update.d.ts +12 -11
  56. package/dist/server-fns/collections/update.js +34 -20
  57. package/dist/server-fns/collections/upload.d.ts +1 -0
  58. package/dist/server-fns/collections/upload.js +3 -2
  59. package/dist/server-fns/document-mutation-errors.d.ts +5 -0
  60. package/dist/server-fns/document-mutation-errors.js +50 -0
  61. package/dist/server-fns/editable-revision.d.ts +9 -0
  62. package/dist/server-fns/editable-revision.js +11 -0
  63. package/dist/server-fns/singleton-document-read.d.ts +1 -1
  64. package/dist/server-fns/singleton-document-read.js +7 -3
  65. package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
  66. package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
  67. package/dist/server-fns/singletons/change-status.d.ts +2 -7
  68. package/dist/server-fns/singletons/change-status.js +4 -1
  69. package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
  70. package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
  71. package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
  72. package/dist/server-fns/singletons/copy-to-locale.js +4 -2
  73. package/dist/server-fns/singletons/get.d.ts +4 -4
  74. package/dist/server-fns/singletons/restore-version.d.ts +2 -7
  75. package/dist/server-fns/singletons/restore-version.js +6 -3
  76. package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
  77. package/dist/server-fns/singletons/schedule-publish.js +4 -2
  78. package/dist/server-fns/singletons/unpublish.d.ts +2 -7
  79. package/dist/server-fns/singletons/unpublish.js +4 -1
  80. package/dist/server-fns/singletons/update.d.ts +4 -11
  81. package/dist/server-fns/singletons/update.js +5 -4
  82. package/dist/server-fns/structural-receipt.d.ts +4 -0
  83. package/dist/server-fns/structural-receipt.js +31 -0
  84. package/package.json +11 -11
  85. package/src/admin-shell/collections/edit.tsx +173 -54
  86. package/src/admin-shell/collections/history.tsx +11 -2
  87. package/src/admin-shell/collections/list.tsx +22 -6
  88. package/src/admin-shell/collections/restore-version-modal.tsx +12 -3
  89. package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
  90. package/src/admin-shell/collections/tree-list.tsx +16 -6
  91. package/src/admin-shell/collections/version-history.tsx +13 -3
  92. package/src/admin-shell/document-mutation-notice.tsx +45 -0
  93. package/src/admin-shell/document-mutation-state.test.tsx +146 -0
  94. package/src/admin-shell/document-mutation-state.ts +95 -0
  95. package/src/admin-shell/scheduled-publications/list.tsx +16 -6
  96. package/src/admin-shell/singletons/history.test.tsx +3 -1
  97. package/src/admin-shell/singletons/history.tsx +10 -2
  98. package/src/admin-shell/singletons/view.test.tsx +59 -18
  99. package/src/admin-shell/singletons/view.tsx +110 -29
  100. package/src/integrations/byline-field-services.ts +2 -3
  101. package/src/routes/create-collection-edit-route.tsx +1 -0
  102. package/src/routes/create-collection-list-route.tsx +13 -2
  103. package/src/routes/create-singleton-route.test.node.ts +10 -2
  104. package/src/routes/create-singleton-route.tsx +6 -4
  105. package/src/server-fns/collections/copy-to-locale.ts +41 -36
  106. package/src/server-fns/collections/create.ts +54 -50
  107. package/src/server-fns/collections/delete-locale.ts +41 -32
  108. package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
  109. package/src/server-fns/collections/delete-outcome.ts +27 -16
  110. package/src/server-fns/collections/delete.ts +44 -38
  111. package/src/server-fns/collections/duplicate.ts +38 -32
  112. package/src/server-fns/collections/get.ts +8 -4
  113. package/src/server-fns/collections/list.ts +8 -3
  114. package/src/server-fns/collections/reorder.ts +32 -124
  115. package/src/server-fns/collections/restore-version.ts +47 -39
  116. package/src/server-fns/collections/revision-input.test.node.ts +108 -0
  117. package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
  118. package/src/server-fns/collections/save-outcome.ts +2 -0
  119. package/src/server-fns/collections/scheduled-publication.ts +138 -111
  120. package/src/server-fns/collections/status.ts +86 -64
  121. package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
  122. package/src/server-fns/collections/tree-mutation.ts +28 -11
  123. package/src/server-fns/collections/tree.ts +150 -122
  124. package/src/server-fns/collections/update.ts +110 -86
  125. package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
  126. package/src/server-fns/collections/upload.ts +81 -77
  127. package/src/server-fns/document-mutation-errors.ts +67 -0
  128. package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
  129. package/src/server-fns/editable-revision.test.node.ts +23 -0
  130. package/src/server-fns/editable-revision.ts +18 -0
  131. package/src/server-fns/singleton-document-read.ts +7 -3
  132. package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
  133. package/src/server-fns/singletons/change-status.ts +10 -3
  134. package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
  135. package/src/server-fns/singletons/copy-to-locale.ts +20 -15
  136. package/src/server-fns/singletons/get.ts +3 -3
  137. package/src/server-fns/singletons/restore-version.ts +17 -12
  138. package/src/server-fns/singletons/schedule-publish.ts +18 -7
  139. package/src/server-fns/singletons/singletons.test.node.ts +63 -25
  140. package/src/server-fns/singletons/unpublish.ts +10 -3
  141. package/src/server-fns/singletons/update.ts +21 -16
  142. package/src/server-fns/structural-receipt.ts +41 -0
@@ -33,11 +33,11 @@ import {
33
33
  hasDeleteSideEffectFailures,
34
34
  scheduleCollectionDocumentPublish,
35
35
  unpublishDocument,
36
- updateCollectionDocumentSystemFields,
37
36
  updateCollectionDocumentWithPatches,
38
37
  updateDocumentStatus,
39
38
  } from '../../server-fns/collections/index.js'
40
39
  import { useNavigate } from '../chrome/loose-router.js'
40
+ import { useDocumentMutationState } from '../document-mutation-state.js'
41
41
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
42
42
  import { ViewMenu } from './view-menu.js'
43
43
  import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
@@ -51,7 +51,7 @@ type EditState = {
51
51
  export const EditView = ({
52
52
  collectionDefinition,
53
53
  adminConfig,
54
- initialData,
54
+ initialData: loadedData,
55
55
  locale,
56
56
  contentLocales,
57
57
  defaultContentLocale,
@@ -66,6 +66,8 @@ export const EditView = ({
66
66
  /** URL-encoded list search state to return to on close — see list-return-state.ts. */
67
67
  returnSearch?: Record<string, unknown>
68
68
  }) => {
69
+ const mutation = useDocumentMutationState(loadedData)
70
+ const initialData = mutation.document ?? loadedData
69
71
  const toastManager = useToastManager()
70
72
  const { t } = useTranslation('byline-admin')
71
73
  const [_editState, setEditState] = useState<EditState>({
@@ -131,9 +133,16 @@ export const EditView = ({
131
133
 
132
134
  const handleStatusChange = async (status: string) => {
133
135
  try {
134
- await updateDocumentStatus({
135
- 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
+ },
136
144
  })
145
+ mutation.adopt(result)
137
146
  const description = t('collections.edit.statusChangedDescription', { status })
138
147
  toastManager.add({
139
148
  title: t('collections.edit.statusUpdateTitle', { label: singular }),
@@ -153,9 +162,16 @@ export const EditView = ({
153
162
  search: (prev: Record<string, unknown>) => ({ ...prev }),
154
163
  })
155
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
+ }
156
172
  console.error('Status change error:', err)
157
173
  const description = t('collections.edit.statusChangeFailedDescription', {
158
- message: (err as Error).message,
174
+ message: t('documentConcurrency.failed'),
159
175
  })
160
176
  toastManager.add({
161
177
  title: t('collections.edit.statusUpdateTitle', { label: singular }),
@@ -181,14 +197,17 @@ export const EditView = ({
181
197
 
182
198
  const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
183
199
  try {
184
- await scheduleCollectionDocumentPublish({
200
+ mutation.assertWritable()
201
+ const result = await scheduleCollectionDocumentPublish({
185
202
  data: {
203
+ expectedRevision: mutation.expectedRevision(),
186
204
  collection: path,
187
205
  id: String(initialData.id),
188
206
  publishAt,
189
207
  expectedVersionId: String(initialData.versionId),
190
208
  },
191
209
  })
210
+ mutation.adopt(result)
192
211
  toastManager.add({
193
212
  title: t('scheduledPublication.toast.scheduledTitle'),
194
213
  description: t('scheduledPublication.toast.scheduledDescription'),
@@ -196,9 +215,16 @@ export const EditView = ({
196
215
  })
197
216
  reloadDocument()
198
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
+ }
199
225
  toastManager.add({
200
226
  title: t('scheduledPublication.toast.failedTitle'),
201
- description: (err as Error).message,
227
+ description: t('documentConcurrency.failed'),
202
228
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
203
229
  })
204
230
  throw err
@@ -207,13 +233,16 @@ export const EditView = ({
207
233
 
208
234
  const handleConfirmScheduledPublication = async () => {
209
235
  try {
210
- await confirmCollectionDocumentScheduledPublish({
236
+ mutation.assertWritable()
237
+ const result = await confirmCollectionDocumentScheduledPublish({
211
238
  data: {
239
+ expectedRevision: mutation.expectedRevision(),
212
240
  collection: path,
213
241
  id: String(initialData.id),
214
242
  expectedVersionId: String(initialData.versionId),
215
243
  },
216
244
  })
245
+ mutation.adopt(result)
217
246
  toastManager.add({
218
247
  title: t('scheduledPublication.toast.confirmedTitle'),
219
248
  description: t('scheduledPublication.toast.confirmedDescription'),
@@ -221,9 +250,16 @@ export const EditView = ({
221
250
  })
222
251
  reloadDocument()
223
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
+ }
224
260
  toastManager.add({
225
261
  title: t('scheduledPublication.toast.failedTitle'),
226
- description: (err as Error).message,
262
+ description: t('documentConcurrency.failed'),
227
263
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
228
264
  })
229
265
  throw err
@@ -232,9 +268,15 @@ export const EditView = ({
232
268
 
233
269
  const handleCancelScheduledPublication = async () => {
234
270
  try {
271
+ mutation.assertWritable()
235
272
  const result = await cancelCollectionDocumentScheduledPublish({
236
- data: { collection: path, id: String(initialData.id) },
273
+ data: {
274
+ expectedRevision: mutation.expectedRevision(),
275
+ collection: path,
276
+ id: String(initialData.id),
277
+ },
237
278
  })
279
+ mutation.adopt({ ...result, documentId: String(initialData.id) })
238
280
  const cancelled = result.status === 'cancelled'
239
281
  toastManager.add({
240
282
  title: cancelled
@@ -252,9 +294,16 @@ export const EditView = ({
252
294
  })
253
295
  reloadDocument()
254
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
+ }
255
304
  toastManager.add({
256
305
  title: t('scheduledPublication.toast.failedTitle'),
257
- description: (err as Error).message,
306
+ description: t('documentConcurrency.failed'),
258
307
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
259
308
  })
260
309
  throw err
@@ -275,7 +324,15 @@ export const EditView = ({
275
324
 
276
325
  const handleUnpublish = async () => {
277
326
  try {
278
- 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)
279
336
  const description = t('collections.edit.unpublishedDescription')
280
337
  toastManager.add({
281
338
  title: t('collections.edit.unpublishTitle', { label: singular }),
@@ -294,9 +351,16 @@ export const EditView = ({
294
351
  search: (prev: Record<string, unknown>) => ({ ...prev }),
295
352
  })
296
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
+ }
297
361
  console.error('Unpublish error:', err)
298
362
  const description = t('collections.edit.unpublishFailedDescription', {
299
- message: (err as Error).message,
363
+ message: t('documentConcurrency.failed'),
300
364
  })
301
365
  toastManager.add({
302
366
  title: t('collections.edit.unpublishTitle', { label: singular }),
@@ -314,8 +378,13 @@ export const EditView = ({
314
378
 
315
379
  const handleDuplicate = async () => {
316
380
  try {
381
+ mutation.assertWritable()
317
382
  const result = await duplicateCollectionDocument({
318
- data: { collection: path, id: String(initialData.id) },
383
+ data: {
384
+ expectedRevision: mutation.expectedRevision(),
385
+ collection: path,
386
+ id: String(initialData.id),
387
+ },
319
388
  })
320
389
  if (hasCommittedDocumentHookFailure(result)) {
321
390
  notifyCommittedHookFailure()
@@ -352,9 +421,16 @@ export const EditView = ({
352
421
  search: (prev: Record<string, unknown>) => ({ ...prev }),
353
422
  })
354
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
+ }
355
431
  console.error('Duplicate error:', err)
356
432
  const description = t('collections.edit.duplicateFailedDescription', {
357
- message: (err as Error).message,
433
+ message: t('documentConcurrency.failed'),
358
434
  })
359
435
  toastManager.add({
360
436
  title: t('collections.edit.duplicateTitle', { label: singular }),
@@ -378,8 +454,10 @@ export const EditView = ({
378
454
  overwrite: boolean
379
455
  }) => {
380
456
  try {
457
+ mutation.assertWritable()
381
458
  const result = await copyDocumentToLocale({
382
459
  data: {
460
+ expectedRevision: mutation.expectedRevision(),
383
461
  collection: path,
384
462
  id: String(initialData.id),
385
463
  sourceLocale: locale ?? defaultContentLocale,
@@ -387,6 +465,7 @@ export const EditView = ({
387
465
  overwrite,
388
466
  },
389
467
  })
468
+ mutation.adopt(result)
390
469
  if (hasCommittedDocumentHookFailure(result)) {
391
470
  notifyCommittedHookFailure()
392
471
  notifyScheduleSuspended()
@@ -438,9 +517,16 @@ export const EditView = ({
438
517
  search: (prev: Record<string, unknown>) => ({ ...prev, locale: targetLocale }),
439
518
  })
440
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
+ }
441
527
  console.error('Copy to locale error:', err)
442
528
  const description = t('collections.edit.copyFailedDescription', {
443
- message: (err as Error).message,
529
+ message: t('documentConcurrency.failed'),
444
530
  })
445
531
  toastManager.add({
446
532
  title: t('collections.edit.copyToLocaleTitle', { label: singular }),
@@ -458,13 +544,16 @@ export const EditView = ({
458
544
 
459
545
  const handleDeleteLocale = async ({ targetLocale }: { targetLocale: string }) => {
460
546
  try {
547
+ mutation.assertWritable()
461
548
  const result = await deleteDocumentLocale({
462
549
  data: {
550
+ expectedRevision: mutation.expectedRevision(),
463
551
  collection: path,
464
552
  id: String(initialData.id),
465
553
  locale: targetLocale,
466
554
  },
467
555
  })
556
+ mutation.adopt(result)
468
557
  if (hasCommittedDocumentHookFailure(result)) {
469
558
  notifyCommittedHookFailure()
470
559
  notifyScheduleSuspended()
@@ -501,9 +590,16 @@ export const EditView = ({
501
590
  search: (prev: Record<string, unknown>) => ({ ...prev, locale: defaultContentLocale }),
502
591
  })
503
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
+ }
504
600
  console.error('Delete locale error:', err)
505
601
  const description = t('collections.edit.deleteLocaleFailedDescription', {
506
- message: (err as Error).message,
602
+ message: t('documentConcurrency.failed'),
507
603
  })
508
604
  toastManager.add({
509
605
  title: t('collections.edit.deleteLocaleTitle', { label: singular }),
@@ -521,9 +617,28 @@ export const EditView = ({
521
617
 
522
618
  const handleDelete = async () => {
523
619
  try {
620
+ mutation.assertWritable()
524
621
  const result = await deleteDocument({
525
- data: { collection: path, id: String(initialData.id) },
622
+ data: {
623
+ expectedRevision: mutation.expectedRevision(),
624
+ collection: path,
625
+ id: String(initialData.id),
626
+ },
526
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
+ }
527
642
  const hasSideEffectFailures = hasDeleteSideEffectFailures(result)
528
643
  const description = hasSideEffectFailures
529
644
  ? t('collections.edit.deletedWithWarningsDescription', { label: singular })
@@ -552,9 +667,16 @@ export const EditView = ({
552
667
  search: returnSearch,
553
668
  })
554
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
+ }
555
677
  console.error('Delete error:', err)
556
678
  const description = t('collections.edit.deleteFailedDescription', {
557
- message: (err as Error).message,
679
+ message: t('documentConcurrency.failed'),
558
680
  })
559
681
  toastManager.add({
560
682
  title: t('collections.edit.deleteTitle', { label: singular }),
@@ -593,41 +715,24 @@ export const EditView = ({
593
715
  systemAvailableLocales?: string[]
594
716
  }) => {
595
717
  try {
596
- let hookFailed = false
597
- // Document-grain system fields write first via their own non-versioned
598
- // path — so a path conflict surfaces before we mint a content version,
599
- // and these immediate writes never reset workflow status. See
600
- // docs/08-internationalization/index.md.
601
- if (pathDirty || availableLocalesDirty) {
602
- await updateCollectionDocumentSystemFields({
603
- data: {
604
- collection: path,
605
- id: String(initialData.id),
606
- locale,
607
- ...(pathDirty ? { path: systemPath ?? null } : {}),
608
- ...(availableLocalesDirty ? { availableLocales: systemAvailableLocales ?? [] } : {}),
609
- },
610
- })
611
- }
612
-
613
- // Content (field data / patches) follows the normal versioned path —
614
- // mints a new draft version. Skipped entirely when only the system
615
- // fields changed, so a path/advertising edit never creates an empty
616
- // content version.
617
- if (contentDirty) {
618
- const result = await updateCollectionDocumentWithPatches({
619
- data: {
620
- collection: path,
621
- id: String(initialData.id),
622
- patches,
623
- versionId: initialData.versionId as string | undefined,
624
- locale,
625
- },
626
- })
627
- hookFailed = hasCommittedDocumentHookFailure(result)
628
- }
629
-
630
- 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
+ ) {
631
736
  notifyScheduleSuspended()
632
737
  }
633
738
 
@@ -662,6 +767,13 @@ export const EditView = ({
662
767
  ignoreBlocker: true,
663
768
  })
664
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
+ }
665
777
  console.error('Network error:', err)
666
778
  const description = t('collections.edit.updateFailedDescription', { label: singularLower })
667
779
  toastManager.add({
@@ -687,6 +799,13 @@ export const EditView = ({
687
799
  <Section>
688
800
  <Container>
689
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')}
690
809
  mode="edit"
691
810
  fields={fields}
692
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}
@@ -26,6 +26,8 @@ import { hasCommittedDocumentHookFailure } from '../../server-fns/collections/sa
26
26
  import styles from './restore-version-modal.module.css'
27
27
 
28
28
  interface RestoreVersionModalProps {
29
+ disabled?: boolean
30
+ onMutationError?: (error: unknown) => unknown
29
31
  versionLabel: string
30
32
  versionNumber: number
31
33
  restoreStatusLabel: string
@@ -35,6 +37,8 @@ interface RestoreVersionModalProps {
35
37
  }
36
38
 
37
39
  export function RestoreVersionModal({
40
+ disabled = false,
41
+ onMutationError,
38
42
  versionLabel,
39
43
  versionNumber,
40
44
  restoreStatusLabel,
@@ -48,7 +52,7 @@ export function RestoreVersionModal({
48
52
  const [pending, setPending] = useState(false)
49
53
 
50
54
  async function handleRestore() {
51
- if (pending) return
55
+ if (pending || disabled) return
52
56
  setPending(true)
53
57
  setError(null)
54
58
  try {
@@ -63,6 +67,11 @@ export function RestoreVersionModal({
63
67
  onClose()
64
68
  await onRestoreComplete()
65
69
  } catch (err) {
70
+ if (onMutationError?.(err)) {
71
+ setPending(false)
72
+ onClose()
73
+ return
74
+ }
66
75
  const code = getErrorCode(err)
67
76
  if (code === 'ERR_INVALID_TRANSITION') {
68
77
  setError(t('collections.restore.errors.alreadyCurrent'))
@@ -113,7 +122,7 @@ export function RestoreVersionModal({
113
122
  intent="noeffect"
114
123
  size="sm"
115
124
  onClick={onClose}
116
- disabled={pending}
125
+ disabled={pending || disabled}
117
126
  className={cx('byline-coll-restore-button', styles.button)}
118
127
  >
119
128
  {t('common.actions.cancel')}
@@ -123,7 +132,7 @@ export function RestoreVersionModal({
123
132
  intent="primary"
124
133
  onClick={handleRestore}
125
134
  style={{ minWidth: '80px' }}
126
- disabled={pending}
135
+ disabled={pending || disabled}
127
136
  className={cx('byline-coll-restore-button', styles.button)}
128
137
  >
129
138
  {pending === true ? (