@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.
- package/dist/admin-shell/collections/create.js +22 -5
- package/dist/admin-shell/collections/edit.d.ts +1 -1
- package/dist/admin-shell/collections/edit.js +222 -34
- package/dist/admin-shell/collections/history.js +4 -2
- package/dist/admin-shell/collections/list.js +30 -6
- package/dist/admin-shell/collections/restore-version-modal.d.ts +3 -1
- package/dist/admin-shell/collections/restore-version-modal.js +23 -6
- package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
- package/dist/admin-shell/collections/tree-list.js +16 -6
- package/dist/admin-shell/collections/version-history.d.ts +2 -1
- package/dist/admin-shell/collections/version-history.js +11 -2
- package/dist/admin-shell/document-mutation-notice.d.ts +6 -0
- package/dist/admin-shell/document-mutation-notice.js +53 -0
- package/dist/admin-shell/document-mutation-state.d.ts +22 -0
- package/dist/admin-shell/document-mutation-state.js +71 -0
- package/dist/admin-shell/document-mutation-state.test.d.ts +1 -0
- package/dist/admin-shell/scheduled-publications/list.js +15 -5
- package/dist/admin-shell/singletons/history.js +4 -2
- package/dist/admin-shell/singletons/view.d.ts +1 -1
- package/dist/admin-shell/singletons/view.js +108 -23
- package/dist/integrations/byline-field-services.js +2 -9
- package/dist/routes/create-collection-edit-route.js +1 -1
- package/dist/routes/create-collection-list-route.d.ts +0 -7
- package/dist/routes/create-collection-list-route.js +7 -9
- package/dist/routes/create-singleton-route.d.ts +2 -0
- package/dist/routes/create-singleton-route.js +4 -3
- package/dist/server-fns/collections/copy-to-locale.d.ts +4 -8
- package/dist/server-fns/collections/copy-to-locale.js +17 -8
- package/dist/server-fns/collections/create.d.ts +2 -8
- package/dist/server-fns/collections/create.js +20 -10
- package/dist/server-fns/collections/delete-locale.d.ts +4 -8
- package/dist/server-fns/collections/delete-locale.js +15 -6
- package/dist/server-fns/collections/delete-outcome.d.ts +7 -3
- package/dist/server-fns/collections/delete-outcome.js +4 -0
- package/dist/server-fns/collections/delete.d.ts +2 -7
- package/dist/server-fns/collections/delete.js +5 -3
- package/dist/server-fns/collections/duplicate.d.ts +4 -8
- package/dist/server-fns/collections/duplicate.js +14 -5
- package/dist/server-fns/collections/get.d.ts +1 -7
- package/dist/server-fns/collections/get.js +5 -4
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/list.d.ts +0 -7
- package/dist/server-fns/collections/list.js +11 -3
- package/dist/server-fns/collections/reorder.d.ts +7 -8
- package/dist/server-fns/collections/reorder.js +20 -76
- package/dist/server-fns/collections/restore-version.d.ts +4 -8
- package/dist/server-fns/collections/restore-version.js +22 -12
- package/dist/server-fns/collections/save-outcome.d.ts +24 -0
- package/dist/server-fns/collections/save-outcome.js +19 -0
- package/dist/server-fns/collections/scheduled-publication.d.ts +49 -9
- package/dist/server-fns/collections/scheduled-publication.js +31 -21
- package/dist/server-fns/collections/status.d.ts +8 -7
- package/dist/server-fns/collections/status.js +13 -5
- package/dist/server-fns/collections/tree-mutation.d.ts +7 -6
- package/dist/server-fns/collections/tree-mutation.js +17 -5
- package/dist/server-fns/collections/tree.d.ts +14 -8
- package/dist/server-fns/collections/tree.js +21 -17
- package/dist/server-fns/collections/update.d.ts +14 -11
- package/dist/server-fns/collections/update.js +48 -24
- package/dist/server-fns/collections/upload.d.ts +1 -0
- package/dist/server-fns/collections/upload.js +3 -2
- package/dist/server-fns/document-mutation-errors.d.ts +5 -0
- package/dist/server-fns/document-mutation-errors.js +50 -0
- package/dist/server-fns/editable-revision.d.ts +9 -0
- package/dist/server-fns/editable-revision.js +11 -0
- package/dist/server-fns/singleton-document-read.d.ts +1 -1
- package/dist/server-fns/singleton-document-read.js +7 -3
- package/dist/server-fns/singletons/cancel-scheduled-publish.d.ts +6 -8
- package/dist/server-fns/singletons/cancel-scheduled-publish.js +4 -1
- package/dist/server-fns/singletons/change-status.d.ts +2 -7
- package/dist/server-fns/singletons/change-status.js +4 -1
- package/dist/server-fns/singletons/confirm-scheduled-publish.d.ts +5 -8
- package/dist/server-fns/singletons/confirm-scheduled-publish.js +4 -2
- package/dist/server-fns/singletons/copy-to-locale.d.ts +2 -7
- package/dist/server-fns/singletons/copy-to-locale.js +16 -5
- package/dist/server-fns/singletons/get.d.ts +4 -4
- package/dist/server-fns/singletons/restore-version.d.ts +2 -7
- package/dist/server-fns/singletons/restore-version.js +13 -1
- package/dist/server-fns/singletons/schedule-publish.d.ts +5 -8
- package/dist/server-fns/singletons/schedule-publish.js +4 -2
- package/dist/server-fns/singletons/unpublish.d.ts +2 -7
- package/dist/server-fns/singletons/unpublish.js +4 -1
- package/dist/server-fns/singletons/update.d.ts +4 -11
- package/dist/server-fns/singletons/update.js +16 -8
- package/dist/server-fns/structural-receipt.d.ts +4 -0
- package/dist/server-fns/structural-receipt.js +31 -0
- package/package.json +11 -11
- package/src/admin-shell/collections/create.tsx +22 -3
- package/src/admin-shell/collections/edit.tsx +233 -61
- package/src/admin-shell/collections/history.tsx +11 -2
- package/src/admin-shell/collections/list.tsx +22 -6
- package/src/admin-shell/collections/restore-version-modal.tsx +23 -5
- package/src/admin-shell/collections/save-navigation.test.tsx +532 -0
- package/src/admin-shell/collections/tree-list.tsx +16 -6
- package/src/admin-shell/collections/version-history.tsx +13 -3
- package/src/admin-shell/document-mutation-notice.tsx +45 -0
- package/src/admin-shell/document-mutation-state.test.tsx +146 -0
- package/src/admin-shell/document-mutation-state.ts +95 -0
- package/src/admin-shell/scheduled-publications/list.tsx +16 -6
- package/src/admin-shell/singletons/history.test.tsx +46 -1
- package/src/admin-shell/singletons/history.tsx +10 -2
- package/src/admin-shell/singletons/view.test.tsx +86 -18
- package/src/admin-shell/singletons/view.tsx +145 -45
- package/src/integrations/byline-field-services.ts +2 -3
- package/src/routes/create-collection-edit-route.tsx +1 -0
- package/src/routes/create-collection-list-route.tsx +13 -2
- package/src/routes/create-singleton-route.test.node.ts +10 -2
- package/src/routes/create-singleton-route.tsx +6 -4
- package/src/server-fns/collections/copy-to-locale.ts +48 -29
- package/src/server-fns/collections/create.ts +56 -44
- package/src/server-fns/collections/delete-locale.ts +48 -25
- package/src/server-fns/collections/delete-outcome.test.node.ts +16 -0
- package/src/server-fns/collections/delete-outcome.ts +27 -16
- package/src/server-fns/collections/delete.ts +44 -38
- package/src/server-fns/collections/duplicate.ts +45 -25
- package/src/server-fns/collections/get.ts +8 -4
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/list.ts +8 -3
- package/src/server-fns/collections/reorder.ts +32 -124
- package/src/server-fns/collections/restore-version.ts +51 -36
- package/src/server-fns/collections/revision-input.test.node.ts +108 -0
- package/src/server-fns/collections/save-outcome.test.node.ts +80 -0
- package/src/server-fns/collections/save-outcome.ts +57 -0
- package/src/server-fns/collections/scheduled-publication.ts +138 -111
- package/src/server-fns/collections/status.ts +86 -64
- package/src/server-fns/collections/tree-mutation.test.node.ts +87 -29
- package/src/server-fns/collections/tree-mutation.ts +28 -11
- package/src/server-fns/collections/tree.ts +150 -122
- package/src/server-fns/collections/update.ts +110 -71
- package/src/server-fns/collections/upload-singleton.test.node.ts +3 -7
- package/src/server-fns/collections/upload.ts +81 -77
- package/src/server-fns/document-mutation-errors.ts +67 -0
- package/src/server-fns/document-mutation-transport.test.node.ts +211 -0
- package/src/server-fns/editable-revision.test.node.ts +23 -0
- package/src/server-fns/editable-revision.ts +18 -0
- package/src/server-fns/singleton-document-read.ts +7 -3
- package/src/server-fns/singletons/cancel-scheduled-publish.ts +10 -3
- package/src/server-fns/singletons/change-status.ts +10 -3
- package/src/server-fns/singletons/confirm-scheduled-publish.ts +12 -6
- package/src/server-fns/singletons/copy-to-locale.ts +20 -8
- package/src/server-fns/singletons/get.ts +3 -3
- package/src/server-fns/singletons/restore-version.ts +17 -3
- package/src/server-fns/singletons/schedule-publish.ts +18 -7
- package/src/server-fns/singletons/singletons.test.node.ts +96 -24
- package/src/server-fns/singletons/unpublish.ts +10 -3
- package/src/server-fns/singletons/update.ts +21 -9
- 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={() =>
|
|
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
|
-
|
|
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: (
|
|
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
|
|
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={() =>
|
|
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({
|
|
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()
|