@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
@@ -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
@@ -55,6 +57,7 @@ interface VersionHistoryRowContext {
55
57
  actionLabel: string
56
58
  restoreStatusLabel: string
57
59
  openCompare: () => void
60
+ mutationsBlocked: boolean
58
61
  openRestore: () => void
59
62
  }
60
63
 
@@ -126,9 +129,10 @@ export function VersionHistoryCore({
126
129
  rowPresentation: VersionHistoryRowPresentation
127
130
  loadHistoricalVersion: HistoricalVersionLoader
128
131
  onPageSizeChange: (pageSize: number) => void
129
- restoreVersion: (versionId: string) => Promise<unknown>
132
+ restoreVersion: (versionId: string, expectedRevision: number) => Promise<unknown>
130
133
  onRestoreComplete: () => void | Promise<void>
131
134
  }) {
135
+ const mutation = useDocumentMutationState(currentDocument)
132
136
  const { t } = useTranslation('byline-admin')
133
137
  const [selectedVersion, setSelectedVersion] = useState<{
134
138
  versionId: string
@@ -150,6 +154,7 @@ export function VersionHistoryCore({
150
154
 
151
155
  return (
152
156
  <>
157
+ <DocumentMutationNotice issue={mutation.issue} />
153
158
  <div className={cx('byline-coll-history-options', styles.options)}>
154
159
  <RouterPager
155
160
  page={data.meta.page}
@@ -195,7 +200,7 @@ export function VersionHistoryCore({
195
200
  setSelectedVersion({ versionId, label: versionLabel })
196
201
  }
197
202
  const openRestore = () => {
198
- if (versionId == null || versionId === currentVersionId) return
203
+ if (mutation.blocked || versionId == null || versionId === currentVersionId) return
199
204
  setRestoreTarget({ versionId, label: versionLabel, versionNumber })
200
205
  }
201
206
 
@@ -228,6 +233,7 @@ export function VersionHistoryCore({
228
233
  actionLabel,
229
234
  restoreStatusLabel,
230
235
  openCompare,
236
+ mutationsBlocked: mutation.blocked,
231
237
  openRestore,
232
238
  })}
233
239
  </Table.Row>
@@ -334,11 +340,15 @@ export function VersionHistoryCore({
334
340
  </Modal.Header>
335
341
  {restoreTarget ? (
336
342
  <RestoreVersionModal
343
+ disabled={mutation.blocked}
344
+ onMutationError={mutation.report}
337
345
  versionLabel={restoreTarget.label}
338
346
  versionNumber={restoreTarget.versionNumber}
339
347
  restoreStatusLabel={restoreStatusLabel}
340
348
  onClose={() => setRestoreTarget(null)}
341
- restoreVersion={() => restoreVersion(restoreTarget.versionId)}
349
+ restoreVersion={() =>
350
+ restoreVersion(restoreTarget.versionId, mutation.expectedRevision())
351
+ }
342
352
  onRestoreComplete={onRestoreComplete}
343
353
  />
344
354
  ) : null}
@@ -0,0 +1,45 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import { useTranslation } from '@byline/i18n/react'
4
+ import { Alert, Button } from '@byline/ui/react'
5
+
6
+ import { getAdminRoutePath } from '../routes/admin-path.js'
7
+ import type { DocumentMutationIssue } from './document-mutation-state.js'
8
+
9
+ /** Persistent recovery for list, history and schedule views without local form drafts. */
10
+ export function DocumentMutationNotice({
11
+ issue,
12
+ scheduleNotice = false,
13
+ }: {
14
+ issue: DocumentMutationIssue | null
15
+ scheduleNotice?: boolean
16
+ }) {
17
+ const { t } = useTranslation('byline-admin')
18
+ const warning = useRef<HTMLDivElement>(null)
19
+ useEffect(() => {
20
+ if (issue) warning.current?.focus()
21
+ }, [issue])
22
+ if (!issue && !scheduleNotice) return null
23
+ return (
24
+ <div ref={warning} tabIndex={-1} role="alert" aria-live="assertive">
25
+ {issue && (
26
+ <Alert close={false} intent="warning" title={t(`documentConcurrency.${issue}Title`)}>
27
+ <p>{t(`documentConcurrency.${issue}`)}</p>
28
+ {issue !== 'committed' && (
29
+ <Button type="button" onClick={() => window.location.reload()}>
30
+ {t('documentConcurrency.reloadAction')}
31
+ </Button>
32
+ )}
33
+ </Alert>
34
+ )}
35
+ {scheduleNotice && (
36
+ <Alert close={false} intent="warning" title={t('documentConcurrency.schedulesTitle')}>
37
+ <p>{t('documentConcurrency.schedules')}</p>
38
+ <a href={getAdminRoutePath('scheduled-publications')}>
39
+ {t('documentConcurrency.reviewSchedules')}
40
+ </a>
41
+ </Alert>
42
+ )}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,146 @@
1
+ import { act } from 'react'
2
+
3
+ import {
4
+ ERR_DOCUMENT_HOOK_COMMITTED,
5
+ ERR_DOCUMENT_STALE,
6
+ ERR_LOCK_CONFLICT,
7
+ ERR_VALIDATION,
8
+ } from '@byline/core'
9
+ import { createRoot, type Root } from 'react-dom/client'
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+
12
+ import { useDocumentMutationState } from './document-mutation-state.js'
13
+
14
+ type Observation = { id: string; versionId: string; revision?: number; fields: { title: string } }
15
+ let state: ReturnType<typeof useDocumentMutationState<Observation>>
16
+ let root: Root
17
+ let container: HTMLDivElement
18
+ function Probe({ observation }: { observation: Observation | null }) {
19
+ state = useDocumentMutationState(observation)
20
+ return null
21
+ }
22
+ const original: Observation = {
23
+ id: 'doc',
24
+ versionId: 'v1',
25
+ revision: 7,
26
+ fields: { title: 'Opened' },
27
+ }
28
+ const render = (observation: Observation | null) =>
29
+ act(() => root.render(<Probe observation={observation} />))
30
+ beforeEach(() => {
31
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
32
+ container = document.createElement('div')
33
+ root = createRoot(container)
34
+ render(original)
35
+ })
36
+ afterEach(() => {
37
+ act(() => root.unmount())
38
+ container.remove()
39
+ Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT')
40
+ })
41
+ const stale = ERR_DOCUMENT_STALE({
42
+ message: 'Test failure',
43
+ details: {
44
+ reason: 'revision_mismatch',
45
+ documentId: 'doc',
46
+ expectedRevision: 7,
47
+ currentRevision: 8,
48
+ },
49
+ })
50
+
51
+ describe('editor observation ownership', () => {
52
+ it('does not attach a background metadata revision to the opened fields', () => {
53
+ render({ ...original, revision: 8 })
54
+ expect(state.document).toBe(original)
55
+ expect(state.expectedRevision()).toBe(7)
56
+ })
57
+ it('adopts its own structural receipt without discarding fields and surfaces schedule suspension', () => {
58
+ act(() =>
59
+ state.adopt({ documentId: 'doc', revision: 8, scheduledPublicationsNeedReconfirmation: true })
60
+ )
61
+ expect(state.document).toBe(original)
62
+ expect(state.expectedRevision()).toBe(8)
63
+ expect(state.scheduleNotice).toBe(true)
64
+ const refreshed = { ...original, revision: 8 }
65
+ render(refreshed)
66
+ expect(state.document).toBe(refreshed)
67
+ })
68
+ it.each([
69
+ ['stale', stale],
70
+ [
71
+ 'reload',
72
+ ERR_VALIDATION({ message: 'Test failure', details: { reason: 'missing_document_revision' } }),
73
+ ],
74
+ [
75
+ 'lock',
76
+ ERR_LOCK_CONFLICT({
77
+ message: 'Test failure',
78
+ details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
79
+ }),
80
+ ],
81
+ ['unavailable', { code: 'ERR_NOT_FOUND' }],
82
+ ])(
83
+ 'blocks every later call after %s without adopting fresh data or a late receipt',
84
+ (issue, error) => {
85
+ act(() => {
86
+ expect(state.report(error)).toBe('blocked')
87
+ })
88
+ render({ ...original, revision: 9 })
89
+ act(() => state.adopt({ documentId: 'doc', revision: 8 }))
90
+ expect(state.issue).toBe(issue)
91
+ expect(state.blocked).toBe(true)
92
+ expect(state.document).toBe(original)
93
+ expect(state.revision).toBe(7)
94
+ expect(() => state.assertWritable()).toThrow()
95
+ expect(() => state.expectedRevision()).toThrow()
96
+ }
97
+ )
98
+ it('keeps a rejected empty singleton empty until a new editor session', () => {
99
+ act(() => root.unmount())
100
+ root = createRoot(container)
101
+ render(null)
102
+ const error = ERR_DOCUMENT_STALE({
103
+ message: 'Test failure',
104
+ details: {
105
+ reason: 'singleton_slot_changed',
106
+ singletonPath: 'settings',
107
+ expectedState: 'empty',
108
+ currentState: 'document',
109
+ },
110
+ })
111
+ act(() => {
112
+ state.report(error)
113
+ })
114
+ render(original)
115
+ expect(state.document).toBeNull()
116
+ expect(state.blocked).toBe(true)
117
+ })
118
+ it('keeps committed hook failures distinct and advances only to the committed revision', () => {
119
+ const error = ERR_DOCUMENT_HOOK_COMMITTED({
120
+ message: 'Test failure',
121
+ details: {
122
+ phase: 'afterUpdate',
123
+ documentId: 'doc',
124
+ documentVersionId: 'v2',
125
+ revision: 8,
126
+ sideEffectCode: 'ERR_UNHANDLED',
127
+ },
128
+ })
129
+ act(() => {
130
+ expect(state.report(error)).toBe('committed')
131
+ })
132
+ expect(state.issue).toBe('committed')
133
+ expect(state.blocked).toBe(false)
134
+ expect(state.expectedRevision()).toBe(8)
135
+ })
136
+ it('does not classify arbitrary conflicts or uncertain driver failures as safe rollback', () => {
137
+ for (const error of [
138
+ { code: 'ERR_CONFLICT' },
139
+ new Error('deadlock SQL secret'),
140
+ { code: 'ECONNRESET' },
141
+ ]) {
142
+ expect(state.report(error)).toBeNull()
143
+ }
144
+ expect(state.blocked).toBe(false)
145
+ })
146
+ })
@@ -0,0 +1,95 @@
1
+ import { useRef, useState } from 'react'
2
+
3
+ import {
4
+ ErrorCodes,
5
+ getDocumentRevisionValidationDetails,
6
+ getDocumentStaleDetails,
7
+ getLockConflictDetails,
8
+ parseDocumentRevision,
9
+ } from '@byline/core'
10
+ import { getDocumentHookCommittedDetails } from '@byline/core/services'
11
+
12
+ import { committedTreeReceipt, structuralReceipt } from '../server-fns/structural-receipt.js'
13
+
14
+ export type DocumentMutationIssue = 'stale' | 'reload' | 'lock' | 'unavailable' | 'committed'
15
+
16
+ function documentMutationIssue(error: unknown): DocumentMutationIssue | null {
17
+ if (getDocumentStaleDetails(error)) return 'stale'
18
+ if (getDocumentRevisionValidationDetails(error)) return 'reload'
19
+ if (getLockConflictDetails(error)) return 'lock'
20
+ if (getDocumentHookCommittedDetails(error) || committedTreeReceipt(error)) return 'committed'
21
+ if (
22
+ typeof error === 'object' &&
23
+ error !== null &&
24
+ Reflect.get(error, 'code') === ErrorCodes.NOT_FOUND
25
+ )
26
+ return 'unavailable'
27
+ return null
28
+ }
29
+
30
+ /** A rejected observation stays frozen until an explicit editor reload. */
31
+ export function useDocumentMutationState<
32
+ T extends { id?: unknown; versionId?: unknown; revision?: unknown },
33
+ >(document: T | null | undefined) {
34
+ const [issue, setIssue] = useState<DocumentMutationIssue | null>(null)
35
+ const [scheduleNotice, setScheduleNotice] = useState(false)
36
+ const [, setReceiptRevision] = useState<number | undefined>()
37
+ const blockedError = useRef<unknown>(undefined)
38
+ const observation = useRef({ identity: document, revision: document?.revision })
39
+ if (
40
+ observation.current.identity !== document &&
41
+ blockedError.current === undefined &&
42
+ document?.revision === observation.current.revision
43
+ ) {
44
+ observation.current = { identity: document, revision: document?.revision }
45
+ }
46
+ const assertWritable = () => {
47
+ if (blockedError.current !== undefined) throw blockedError.current
48
+ }
49
+ const expectedRevision = () => {
50
+ assertWritable()
51
+ return parseDocumentRevision(observation.current.revision)
52
+ }
53
+ const adopt = (receipt: {
54
+ documentId: string
55
+ revision: number
56
+ scheduledPublicationsNeedReconfirmation?: boolean
57
+ }) => {
58
+ if (blockedError.current !== undefined) return
59
+ if (document?.id != null && receipt.documentId !== document.id) return
60
+ observation.current.revision = parseDocumentRevision(receipt.revision)
61
+ setReceiptRevision(receipt.revision)
62
+ if (receipt.scheduledPublicationsNeedReconfirmation) setScheduleNotice(true)
63
+ }
64
+ const reportStructure = (result: unknown) => {
65
+ const receipt = structuralReceipt(result)
66
+ if (receipt) adopt(receipt)
67
+ }
68
+ const report = (error: unknown): 'blocked' | 'committed' | null => {
69
+ const kind = documentMutationIssue(error)
70
+ if (kind === null) return null
71
+ if (kind === 'committed') {
72
+ if (blockedError.current !== undefined) return 'blocked'
73
+ const details = getDocumentHookCommittedDetails(error) ?? committedTreeReceipt(error)
74
+ if (details) adopt(details)
75
+ setIssue(kind)
76
+ return 'committed'
77
+ }
78
+ blockedError.current = error
79
+ setIssue(kind)
80
+ return 'blocked'
81
+ }
82
+ return {
83
+ issue,
84
+ blocked: issue !== null && issue !== 'committed',
85
+ scheduleNotice,
86
+ document: observation.current.identity,
87
+ revision:
88
+ typeof observation.current.revision === 'number' ? observation.current.revision : undefined,
89
+ expectedRevision,
90
+ assertWritable,
91
+ adopt,
92
+ report,
93
+ reportStructure,
94
+ }
95
+ }
@@ -1,4 +1,7 @@
1
- 'use client'
1
+ import { DocumentMutationNotice } from '../document-mutation-notice.js'
2
+ import { useDocumentMutationState } from '../document-mutation-state.js'
3
+
4
+ ;('use client')
2
5
 
3
6
  /**
4
7
  * This Source Code is subject to the terms of the Mozilla Public
@@ -60,6 +63,7 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
60
63
  state?: 'armed' | 'needs_reconfirm'
61
64
  page?: number
62
65
  }
66
+ const mutation = useDocumentMutationState(undefined)
63
67
  const [cancelling, setCancelling] = useState<string | null>(null)
64
68
 
65
69
  const applySearch = (patch: Record<string, unknown>) => {
@@ -69,11 +73,13 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
69
73
  })
70
74
  }
71
75
 
72
- const cancel = async (collection: string, documentId: string) => {
76
+ const cancel = async (collection: string, documentId: string, expectedRevision: number) => {
77
+ if (mutation.blocked || cancelling) return
73
78
  setCancelling(documentId)
74
79
  try {
80
+ mutation.assertWritable()
75
81
  const result = await cancelCollectionDocumentScheduledPublish({
76
- data: { collection, id: documentId },
82
+ data: { collection, id: documentId, expectedRevision },
77
83
  })
78
84
  const cancelled = result.status === 'cancelled'
79
85
  toastManager.add({
@@ -95,9 +101,10 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
95
101
  search: location.search as Record<string, unknown>,
96
102
  })
97
103
  } catch (error) {
104
+ if (mutation.report(error)) return
98
105
  toastManager.add({
99
106
  title: t('scheduledPublication.toast.failedTitle'),
100
- description: (error as Error).message,
107
+ description: t('documentConcurrency.failed'),
101
108
  data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
102
109
  })
103
110
  } finally {
@@ -110,6 +117,7 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
110
117
  return (
111
118
  <Section>
112
119
  <Container>
120
+ <DocumentMutationNotice issue={mutation.issue} />
113
121
  <div className={cx('byline-scheduled-list-head', styles.head)}>
114
122
  <h1 className={styles.title}>{t('scheduledPublication.list.title')}</h1>
115
123
  <p className={styles.intro}>{t('scheduledPublication.list.intro')}</p>
@@ -257,13 +265,15 @@ export function ScheduledPublicationsView({ data }: { data: ScheduledPublication
257
265
  size="xs"
258
266
  type="button"
259
267
  intent="danger"
260
- disabled={cancelling === schedule.documentId}
268
+ disabled={mutation.blocked || cancelling !== null}
261
269
  // The column header already says Actions and the row
262
270
  // is a schedule, so the short label carries here —
263
271
  // unlike the editor's menu, where "Cancel schedule"
264
272
  // has to distinguish itself from cancelling an edit.
265
273
  aria-label={t('scheduledPublication.actions.cancel')}
266
- onClick={() => cancel(schedule.collectionPath, schedule.documentId)}
274
+ onClick={() =>
275
+ cancel(schedule.collectionPath, schedule.documentId, schedule.revision)
276
+ }
267
277
  >
268
278
  {t('common.actions.cancel')}
269
279
  </Button>
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
21
21
  findSingletonByVersion: vi.fn(async () => ({})),
22
22
  pagerProps: [] as Array<{ componentName: string; page: number; count: number }>,
23
23
  diffModalProps: [] as Array<Record<string, unknown>>,
24
+ toast: vi.fn(),
24
25
  location: {
25
26
  pathname: '/admin/singletons/site-settings/history',
26
27
  search: {} as Record<string, unknown>,
@@ -103,6 +104,7 @@ vi.mock('@byline/ui/react', () => {
103
104
  Section: Div,
104
105
  Select: () => <select aria-label="page size" />,
105
106
  Table,
107
+ useToastManager: () => ({ add: mocks.toast }),
106
108
  }
107
109
  })
108
110
 
@@ -192,6 +194,7 @@ const data: VersionHistoryData = {
192
194
  }
193
195
 
194
196
  const currentDocument = {
197
+ revision: 7,
195
198
  id: 'document-settings',
196
199
  versionId: 'version-current',
197
200
  status: 'published',
@@ -264,7 +267,7 @@ describe('singleton version history', () => {
264
267
  await act(async () => confirm?.click())
265
268
 
266
269
  expect(mocks.restoreSingleton).toHaveBeenCalledWith({
267
- data: { singleton: 'site-settings', versionId: 'version-old' },
270
+ data: { singleton: 'site-settings', versionId: 'version-old', expectedRevision: 7 },
268
271
  })
269
272
  expect(mocks.invalidate).toHaveBeenCalledTimes(1)
270
273
  expect(mocks.navigate).toHaveBeenLastCalledWith({
@@ -393,6 +396,7 @@ describe('singleton version history', () => {
393
396
 
394
397
  expect(mocks.restoreCollection).toHaveBeenCalledWith({
395
398
  data: {
399
+ expectedRevision: 7,
396
400
  collection: 'articles',
397
401
  id: 'document-article',
398
402
  versionId: 'version-old',
@@ -404,4 +408,45 @@ describe('singleton version history', () => {
404
408
  })
405
409
  dispose()
406
410
  })
411
+
412
+ it('closes and refreshes after a restore whose afterSave hook failed post-commit', async () => {
413
+ mocks.restoreSingleton.mockResolvedValueOnce({
414
+ status: 'committed-hook-failed',
415
+ documentId: 'document-settings',
416
+ documentVersionId: 'version-restored',
417
+ sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
418
+ })
419
+ render(
420
+ <SingletonHistoryView
421
+ singletonDefinition={singletonDefinition}
422
+ data={data}
423
+ currentDocument={currentDocument}
424
+ contentLocales={[]}
425
+ defaultContentLocale="en"
426
+ workflowStatuses={[]}
427
+ />
428
+ )
429
+
430
+ const restore = [...container.querySelectorAll('button')].find(
431
+ (button) => button.textContent === 'Restore'
432
+ )
433
+ await act(async () => restore?.click())
434
+ const confirm = [...container.querySelectorAll('button')].find(
435
+ (button) => button.textContent === 'Restore as Published'
436
+ )
437
+ await act(async () => confirm?.click())
438
+
439
+ expect(mocks.toast).toHaveBeenCalledWith({
440
+ title: 'collections.save.hookFailedToast',
441
+ description: 'collections.save.hookFailedDescription',
442
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
443
+ })
444
+ expect(mocks.invalidate).toHaveBeenCalledOnce()
445
+ expect(mocks.navigate).toHaveBeenLastCalledWith({
446
+ to: '/admin/singletons/$singleton',
447
+ params: { singleton: 'site-settings' },
448
+ search: {},
449
+ })
450
+ dispose()
451
+ })
407
452
  })
@@ -89,6 +89,7 @@ export function SingletonHistoryView({
89
89
  currentVersionId,
90
90
  actorLabel,
91
91
  restoreStatusLabel,
92
+ mutationsBlocked,
92
93
  openRestore,
93
94
  }) => (
94
95
  <>
@@ -106,6 +107,7 @@ export function SingletonHistoryView({
106
107
  variant="outlined"
107
108
  size="xs"
108
109
  intent="noeffect"
110
+ disabled={mutationsBlocked}
109
111
  onClick={openRestore}
110
112
  className={cx('byline-coll-history-restore-button', historyStyles.restoreButton)}
111
113
  title={t('collections.history.restoreButtonTitle', {
@@ -166,8 +168,14 @@ export function SingletonHistoryView({
166
168
  return historical as unknown as Record<string, unknown>
167
169
  }}
168
170
  onPageSizeChange={handlePageSizeChange}
169
- restoreVersion={(versionId) =>
170
- restoreSingletonVersion({ data: { singleton, versionId } })
171
+ restoreVersion={(versionId, expectedRevision) =>
172
+ restoreSingletonVersion({
173
+ data: {
174
+ expectedRevision,
175
+ singleton,
176
+ versionId,
177
+ },
178
+ })
171
179
  }
172
180
  onRestoreComplete={async () => {
173
181
  await router.invalidate()