@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.
- package/dist/admin-shell/collections/edit.d.ts +1 -1
- package/dist/admin-shell/collections/edit.js +151 -31
- 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 +9 -4
- 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 +98 -19
- 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 +2 -7
- package/dist/server-fns/collections/copy-to-locale.js +4 -2
- package/dist/server-fns/collections/create.d.ts +1 -7
- package/dist/server-fns/collections/create.js +5 -3
- package/dist/server-fns/collections/delete-locale.d.ts +2 -7
- package/dist/server-fns/collections/delete-locale.js +4 -2
- 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 +2 -7
- package/dist/server-fns/collections/duplicate.js +5 -3
- package/dist/server-fns/collections/get.d.ts +1 -7
- package/dist/server-fns/collections/get.js +5 -4
- 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 +3 -7
- package/dist/server-fns/collections/restore-version.js +5 -2
- package/dist/server-fns/collections/save-outcome.d.ts +1 -0
- package/dist/server-fns/collections/save-outcome.js +1 -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 +12 -11
- package/dist/server-fns/collections/update.js +34 -20
- 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 +4 -2
- 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 +6 -3
- 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 +5 -4
- 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/edit.tsx +173 -54
- 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 +12 -3
- package/src/admin-shell/collections/save-navigation.test.tsx +156 -8
- 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 +3 -1
- package/src/admin-shell/singletons/history.tsx +10 -2
- package/src/admin-shell/singletons/view.test.tsx +59 -18
- package/src/admin-shell/singletons/view.tsx +110 -29
- 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 +41 -36
- package/src/server-fns/collections/create.ts +54 -50
- package/src/server-fns/collections/delete-locale.ts +41 -32
- 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 +38 -32
- package/src/server-fns/collections/get.ts +8 -4
- 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 +47 -39
- package/src/server-fns/collections/revision-input.test.node.ts +108 -0
- package/src/server-fns/collections/save-outcome.test.node.ts +3 -0
- package/src/server-fns/collections/save-outcome.ts +2 -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 -86
- 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 -15
- package/src/server-fns/singletons/get.ts +3 -3
- package/src/server-fns/singletons/restore-version.ts +17 -12
- package/src/server-fns/singletons/schedule-publish.ts +18 -7
- package/src/server-fns/singletons/singletons.test.node.ts +63 -25
- package/src/server-fns/singletons/unpublish.ts +10 -3
- package/src/server-fns/singletons/update.ts +21 -16
- package/src/server-fns/structural-receipt.ts +41 -0
|
@@ -9,11 +9,23 @@
|
|
|
9
9
|
import type React from 'react'
|
|
10
10
|
import { act } from 'react'
|
|
11
11
|
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_WORKFLOW,
|
|
14
|
+
ERR_DOCUMENT_STALE,
|
|
15
|
+
ERR_LOCK_CONFLICT,
|
|
16
|
+
type MultiCollectionDefinition,
|
|
17
|
+
SINGLE_STATUS_WORKFLOW,
|
|
18
|
+
} from '@byline/core'
|
|
13
19
|
import { createRoot, type Root } from 'react-dom/client'
|
|
14
20
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
15
21
|
|
|
16
22
|
const mocks = vi.hoisted(() => ({
|
|
23
|
+
status: vi.fn(),
|
|
24
|
+
deletion: vi.fn(),
|
|
25
|
+
schedule: vi.fn(),
|
|
26
|
+
confirm: vi.fn(),
|
|
27
|
+
cancelSchedule: vi.fn(),
|
|
28
|
+
unpublish: vi.fn(),
|
|
17
29
|
copyToLocale: vi.fn(),
|
|
18
30
|
create: vi.fn(),
|
|
19
31
|
deleteLocale: vi.fn(),
|
|
@@ -60,21 +72,21 @@ vi.mock('../../routes/list-return-storage.js', () => ({
|
|
|
60
72
|
}))
|
|
61
73
|
|
|
62
74
|
vi.mock('../../server-fns/collections/index.js', () => ({
|
|
63
|
-
cancelCollectionDocumentScheduledPublish:
|
|
64
|
-
confirmCollectionDocumentScheduledPublish:
|
|
75
|
+
cancelCollectionDocumentScheduledPublish: mocks.cancelSchedule,
|
|
76
|
+
confirmCollectionDocumentScheduledPublish: mocks.confirm,
|
|
65
77
|
copyDocumentToLocale: mocks.copyToLocale,
|
|
66
78
|
createCollectionDocument: mocks.create,
|
|
67
|
-
deleteDocument:
|
|
79
|
+
deleteDocument: mocks.deletion,
|
|
68
80
|
deleteDocumentLocale: mocks.deleteLocale,
|
|
69
81
|
duplicateCollectionDocument: mocks.duplicate,
|
|
70
82
|
hasCommittedDocumentHookFailure: (result: { status: string }) =>
|
|
71
83
|
result.status === 'committed-hook-failed',
|
|
72
84
|
hasDeleteSideEffectFailures: () => false,
|
|
73
|
-
scheduleCollectionDocumentPublish:
|
|
74
|
-
unpublishDocument:
|
|
85
|
+
scheduleCollectionDocumentPublish: mocks.schedule,
|
|
86
|
+
unpublishDocument: mocks.unpublish,
|
|
75
87
|
updateCollectionDocumentSystemFields: mocks.updateSystemFields,
|
|
76
88
|
updateCollectionDocumentWithPatches: mocks.update,
|
|
77
|
-
updateDocumentStatus:
|
|
89
|
+
updateDocumentStatus: mocks.status,
|
|
78
90
|
}))
|
|
79
91
|
|
|
80
92
|
vi.mock('../chrome/loose-router.js', () => ({
|
|
@@ -106,6 +118,7 @@ const collection = {
|
|
|
106
118
|
const initialData = {
|
|
107
119
|
id: 'language-en',
|
|
108
120
|
versionId: 'version-1',
|
|
121
|
+
revision: 7,
|
|
109
122
|
status: 'published',
|
|
110
123
|
fields: { name: 'English' },
|
|
111
124
|
}
|
|
@@ -127,11 +140,12 @@ beforeEach(() => {
|
|
|
127
140
|
documentId: 'language-th',
|
|
128
141
|
documentVersionId: 'version-created',
|
|
129
142
|
})
|
|
130
|
-
mocks.update.mockResolvedValue({ status: 'ok' })
|
|
143
|
+
mocks.update.mockResolvedValue({ status: 'ok', documentId: 'language-en', revision: 8 })
|
|
131
144
|
mocks.updateSystemFields.mockResolvedValue({ status: 'ok' })
|
|
132
145
|
mocks.copyToLocale.mockResolvedValue({
|
|
133
146
|
documentId: 'language-en',
|
|
134
147
|
documentVersionId: 'version-2',
|
|
148
|
+
revision: 8,
|
|
135
149
|
sourceLocale: 'en',
|
|
136
150
|
targetLocale: 'th',
|
|
137
151
|
fieldsUpdated: 1,
|
|
@@ -139,6 +153,7 @@ beforeEach(() => {
|
|
|
139
153
|
mocks.deleteLocale.mockResolvedValue({
|
|
140
154
|
documentId: 'language-en',
|
|
141
155
|
documentVersionId: 'version-2',
|
|
156
|
+
revision: 8,
|
|
142
157
|
locale: 'th',
|
|
143
158
|
})
|
|
144
159
|
mocks.duplicate.mockResolvedValue({
|
|
@@ -253,6 +268,44 @@ describe('collection post-save navigation', () => {
|
|
|
253
268
|
})
|
|
254
269
|
})
|
|
255
270
|
|
|
271
|
+
it('sends content and metadata in one save using the loaded revision', async () => {
|
|
272
|
+
act(() => {
|
|
273
|
+
root.render(
|
|
274
|
+
<EditView
|
|
275
|
+
collectionDefinition={collection}
|
|
276
|
+
initialData={initialData as never}
|
|
277
|
+
contentLocales={[{ code: 'en', label: 'English' }]}
|
|
278
|
+
defaultContentLocale="en"
|
|
279
|
+
locale="en"
|
|
280
|
+
/>
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
const patches = [{ kind: 'field.set', path: 'name', value: 'English' }]
|
|
284
|
+
await act(async () => {
|
|
285
|
+
await latestSubmit()({
|
|
286
|
+
data: { name: 'English' },
|
|
287
|
+
patches,
|
|
288
|
+
contentDirty: true,
|
|
289
|
+
pathDirty: true,
|
|
290
|
+
systemPath: 'renamed',
|
|
291
|
+
availableLocalesDirty: true,
|
|
292
|
+
systemAvailableLocales: ['en', 'fr'],
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
expect(mocks.update).toHaveBeenCalledExactlyOnceWith({
|
|
296
|
+
data: {
|
|
297
|
+
collection: 'languages',
|
|
298
|
+
id: 'language-en',
|
|
299
|
+
expectedRevision: 7,
|
|
300
|
+
patches,
|
|
301
|
+
locale: 'en',
|
|
302
|
+
path: 'renamed',
|
|
303
|
+
availableLocales: ['en', 'fr'],
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
expect(mocks.updateSystemFields).not.toHaveBeenCalled()
|
|
307
|
+
})
|
|
308
|
+
|
|
256
309
|
it('bypasses the dirty-form blocker during the successful edit refresh', async () => {
|
|
257
310
|
act(() => {
|
|
258
311
|
root.render(
|
|
@@ -289,6 +342,7 @@ describe('collection post-save navigation', () => {
|
|
|
289
342
|
status: 'committed-hook-failed',
|
|
290
343
|
documentId: 'language-en',
|
|
291
344
|
documentVersionId: 'version-2',
|
|
345
|
+
revision: 8,
|
|
292
346
|
sideEffectFailure: { phase: 'afterUpdate', code: 'ERR_UNHANDLED' },
|
|
293
347
|
})
|
|
294
348
|
act(() => {
|
|
@@ -351,6 +405,7 @@ describe('collection post-save navigation', () => {
|
|
|
351
405
|
status: 'committed-hook-failed',
|
|
352
406
|
documentId: testCase.expectedId,
|
|
353
407
|
documentVersionId: 'version-committed',
|
|
408
|
+
revision: 8,
|
|
354
409
|
sideEffectFailure: { phase: testCase.phase, code: 'ERR_UNHANDLED' },
|
|
355
410
|
})
|
|
356
411
|
act(() => {
|
|
@@ -382,3 +437,96 @@ describe('collection post-save navigation', () => {
|
|
|
382
437
|
})
|
|
383
438
|
})
|
|
384
439
|
})
|
|
440
|
+
|
|
441
|
+
describe('collection editor mutation lockout', () => {
|
|
442
|
+
const stale = ERR_DOCUMENT_STALE({
|
|
443
|
+
message: 'Test failure',
|
|
444
|
+
details: {
|
|
445
|
+
reason: 'revision_mismatch',
|
|
446
|
+
documentId: 'language-en',
|
|
447
|
+
expectedRevision: 7,
|
|
448
|
+
currentRevision: 8,
|
|
449
|
+
},
|
|
450
|
+
})
|
|
451
|
+
const opened = {
|
|
452
|
+
...initialData,
|
|
453
|
+
_publishedVersion: initialData,
|
|
454
|
+
status: 'draft',
|
|
455
|
+
_scheduledPublicationEnabled: true,
|
|
456
|
+
_canSchedulePublication: true,
|
|
457
|
+
_scheduledPublish: { state: 'needs_reconfirm' },
|
|
458
|
+
}
|
|
459
|
+
const renderEditor = () =>
|
|
460
|
+
act(() =>
|
|
461
|
+
root.render(
|
|
462
|
+
<EditView
|
|
463
|
+
collectionDefinition={{ ...collection, workflow: DEFAULT_WORKFLOW }}
|
|
464
|
+
initialData={opened as never}
|
|
465
|
+
contentLocales={[
|
|
466
|
+
{ code: 'en', label: 'English' },
|
|
467
|
+
{ code: 'th', label: 'Thai' },
|
|
468
|
+
]}
|
|
469
|
+
defaultContentLocale="en"
|
|
470
|
+
locale="en"
|
|
471
|
+
/>
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
const operations = [
|
|
475
|
+
['onSubmit', mocks.update, { patches: [], contentDirty: true }],
|
|
476
|
+
['onStatusChange', mocks.status, 'published'],
|
|
477
|
+
['onDelete', mocks.deletion, undefined],
|
|
478
|
+
['onDuplicate', mocks.duplicate, undefined],
|
|
479
|
+
['onCopyToLocale', mocks.copyToLocale, { targetLocale: 'th', overwrite: true }],
|
|
480
|
+
['onDeleteLocale', mocks.deleteLocale, { targetLocale: 'th' }],
|
|
481
|
+
['onUnpublish', mocks.unpublish, undefined],
|
|
482
|
+
['onSchedulePublication', mocks.schedule, { publishAt: '2030-01-01T00:00:00Z' }],
|
|
483
|
+
['onConfirmScheduledPublication', mocks.confirm, undefined],
|
|
484
|
+
['onCancelScheduledPublication', mocks.cancelSchedule, undefined],
|
|
485
|
+
] as const
|
|
486
|
+
it.each(operations)(
|
|
487
|
+
'blocks subsequent writes after %s reports stale',
|
|
488
|
+
async (name, operation, argument) => {
|
|
489
|
+
operation.mockRejectedValueOnce(stale)
|
|
490
|
+
renderEditor()
|
|
491
|
+
await act(async () => {
|
|
492
|
+
await expect(latestAction(name)(argument)).rejects.toBe(stale)
|
|
493
|
+
})
|
|
494
|
+
expect(mocks.formProps.at(-1)).toMatchObject({
|
|
495
|
+
mutationIssue: 'stale',
|
|
496
|
+
mutationsBlocked: true,
|
|
497
|
+
observedRevision: 7,
|
|
498
|
+
})
|
|
499
|
+
for (const [otherName, , otherArgument] of operations) {
|
|
500
|
+
await act(async () => {
|
|
501
|
+
await expect(latestAction(otherName)(otherArgument)).rejects.toBe(stale)
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
expect(operations.reduce((total, [, mock]) => total + mock.mock.calls.length, 0)).toBe(1)
|
|
505
|
+
expect(mocks.navigate).not.toHaveBeenCalled()
|
|
506
|
+
expect(mocks.toastAdd).not.toHaveBeenCalled()
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
it.each([
|
|
510
|
+
['onSubmit', mocks.update, { patches: [], contentDirty: true }],
|
|
511
|
+
['onDelete', mocks.deletion, undefined],
|
|
512
|
+
] as const)(
|
|
513
|
+
'presents confirmed lock rollback safely for %s',
|
|
514
|
+
async (name, operation, argument) => {
|
|
515
|
+
const failure = ERR_LOCK_CONFLICT({
|
|
516
|
+
message: 'raw InnoDB private SQL',
|
|
517
|
+
details: { reason: 'lock_conflict', rolledBack: true, retryable: true },
|
|
518
|
+
})
|
|
519
|
+
operation.mockRejectedValueOnce(failure)
|
|
520
|
+
renderEditor()
|
|
521
|
+
await act(async () => {
|
|
522
|
+
await expect(latestAction(name)(argument)).rejects.toBe(failure)
|
|
523
|
+
})
|
|
524
|
+
expect(mocks.formProps.at(-1)).toMatchObject({
|
|
525
|
+
mutationIssue: 'lock',
|
|
526
|
+
mutationsBlocked: true,
|
|
527
|
+
observedRevision: 7,
|
|
528
|
+
})
|
|
529
|
+
expect(mocks.toastAdd).not.toHaveBeenCalled()
|
|
530
|
+
}
|
|
531
|
+
)
|
|
532
|
+
})
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DocumentMutationNotice } from '../document-mutation-notice.js'
|
|
2
|
+
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
1
3
|
/**
|
|
2
4
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
5
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -144,6 +146,7 @@ export const TreeListView = ({
|
|
|
144
146
|
/** Header action components (`CollectionAdminConfig.listActions`). */
|
|
145
147
|
listActions?: Array<(props: ListActionComponentProps) => React.ReactNode>
|
|
146
148
|
}) => {
|
|
149
|
+
const mutation = useDocumentMutationState(undefined)
|
|
147
150
|
const { t } = useTranslation('byline-admin')
|
|
148
151
|
const router = useRouter()
|
|
149
152
|
const toastManager = useToastManager()
|
|
@@ -159,10 +162,14 @@ export const TreeListView = ({
|
|
|
159
162
|
const [moveController] = useState(() => createAdminTreeMoveController())
|
|
160
163
|
const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
|
|
161
164
|
useEffect(() => {
|
|
162
|
-
if (
|
|
165
|
+
if (
|
|
166
|
+
mutation.blocked ||
|
|
167
|
+
!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)
|
|
168
|
+
)
|
|
169
|
+
return
|
|
163
170
|
preserveOptimisticForRows.current = null
|
|
164
171
|
setLocalPlaced(rows.filter((r) => !r.unplaced))
|
|
165
|
-
}, [rows, isMoving])
|
|
172
|
+
}, [rows, isMoving, mutation.blocked])
|
|
166
173
|
|
|
167
174
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
168
175
|
const [overId, setOverId] = useState<string | null>(null)
|
|
@@ -186,13 +193,13 @@ export const TreeListView = ({
|
|
|
186
193
|
}
|
|
187
194
|
|
|
188
195
|
const handleDragStart = ({ active }: DragStartEvent) => {
|
|
189
|
-
if (!moveController.isMoving()) setActiveId(String(active.id))
|
|
196
|
+
if (!mutation.blocked && !moveController.isMoving()) setActiveId(String(active.id))
|
|
190
197
|
}
|
|
191
198
|
const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
|
|
192
199
|
const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
|
|
193
200
|
|
|
194
201
|
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
|
|
195
|
-
if (moveController.isMoving()) {
|
|
202
|
+
if (mutation.blocked || moveController.isMoving()) {
|
|
196
203
|
resetDnd()
|
|
197
204
|
return
|
|
198
205
|
}
|
|
@@ -231,10 +238,12 @@ export const TreeListView = ({
|
|
|
231
238
|
return
|
|
232
239
|
}
|
|
233
240
|
|
|
241
|
+
if ('receipt' in outcome) mutation.reportStructure(outcome.receipt)
|
|
242
|
+
const handled = 'error' in outcome ? mutation.report(outcome.error) : null
|
|
234
243
|
const effects = getAdminTreeMoveUiEffects(outcome)
|
|
235
244
|
if (effects.rollback) setLocalPlaced(current)
|
|
236
245
|
|
|
237
|
-
if (effects.structuralFailure != null) {
|
|
246
|
+
if (!handled && effects.structuralFailure != null) {
|
|
238
247
|
const conflict = effects.structuralFailure === 'conflict'
|
|
239
248
|
toastManager.add({
|
|
240
249
|
title: t(
|
|
@@ -318,6 +327,7 @@ export const TreeListView = ({
|
|
|
318
327
|
return (
|
|
319
328
|
<Section>
|
|
320
329
|
<Container>
|
|
330
|
+
<DocumentMutationNotice issue={mutation.issue} scheduleNotice={mutation.scheduleNotice} />
|
|
321
331
|
<div className={cx('byline-coll-list-head', styles.head)}>
|
|
322
332
|
<h1 className={cx('byline-coll-list-title', styles.title)}>{collectionLabels.plural}</h1>
|
|
323
333
|
<span className={cx('byline-coll-list-stats', styles.stats)}>
|
|
@@ -380,7 +390,7 @@ export const TreeListView = ({
|
|
|
380
390
|
key={row.id}
|
|
381
391
|
id={row.id}
|
|
382
392
|
dragging={row.id === activeId}
|
|
383
|
-
disabled={isMoving}
|
|
393
|
+
disabled={mutation.blocked || isMoving}
|
|
384
394
|
dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
|
|
385
395
|
>
|
|
386
396
|
{renderCells(row, depth)}
|
|
@@ -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
|
+
}
|