@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
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
import { act } from 'react'
|
|
10
10
|
|
|
11
11
|
import { BylineFieldServicesProvider } from '@byline/admin/react'
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
defineAdminConfig,
|
|
14
|
+
defineSingleton,
|
|
15
|
+
ERR_DOCUMENT_STALE,
|
|
16
|
+
type SingletonDefinition,
|
|
17
|
+
} from '@byline/core'
|
|
13
18
|
import { adminTranslations } from '@byline/i18n/admin'
|
|
14
19
|
import { I18nProvider } from '@byline/i18n/react'
|
|
15
20
|
import { createRoot, type Root } from 'react-dom/client'
|
|
@@ -54,6 +59,14 @@ vi.mock('../../server-fns/singletons/index.js', () => ({
|
|
|
54
59
|
copySingletonToLocale: mocks.copyToLocale,
|
|
55
60
|
}))
|
|
56
61
|
|
|
62
|
+
vi.mock('../../server-fns/collections/index.js', () => ({
|
|
63
|
+
hasCommittedDocumentHookFailure: (result: unknown) =>
|
|
64
|
+
typeof result === 'object' &&
|
|
65
|
+
result != null &&
|
|
66
|
+
'status' in result &&
|
|
67
|
+
result.status === 'committed-hook-failed',
|
|
68
|
+
}))
|
|
69
|
+
|
|
57
70
|
vi.mock('../../server-fns/preview/index.js', () => ({
|
|
58
71
|
enablePreviewModeFn: vi.fn(async () => {}),
|
|
59
72
|
}))
|
|
@@ -141,6 +154,7 @@ const locales = [
|
|
|
141
154
|
const loadedDocument = {
|
|
142
155
|
id: 'doc-settings',
|
|
143
156
|
versionId: 'version-1',
|
|
157
|
+
revision: 7,
|
|
144
158
|
path: '__singleton__/site-settings',
|
|
145
159
|
status: 'draft',
|
|
146
160
|
fields: { title: 'Loaded title', attachment: null },
|
|
@@ -225,13 +239,29 @@ function latestToastDescription(): string {
|
|
|
225
239
|
beforeEach(() => {
|
|
226
240
|
vi.clearAllMocks()
|
|
227
241
|
mocks.guardStates.length = 0
|
|
228
|
-
mocks.update.mockResolvedValue({
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
mocks.
|
|
234
|
-
|
|
242
|
+
mocks.update.mockResolvedValue({
|
|
243
|
+
documentId: 'doc-settings',
|
|
244
|
+
revision: 8,
|
|
245
|
+
versionId: 'version-next',
|
|
246
|
+
})
|
|
247
|
+
mocks.changeStatus.mockResolvedValue({
|
|
248
|
+
documentId: 'doc-settings',
|
|
249
|
+
revision: 8,
|
|
250
|
+
newStatus: 'published',
|
|
251
|
+
})
|
|
252
|
+
mocks.unpublish.mockResolvedValue({ documentId: 'doc-settings', revision: 8, archivedCount: 1 })
|
|
253
|
+
mocks.schedule.mockResolvedValue({ documentId: 'doc-settings', revision: 8, state: 'armed' })
|
|
254
|
+
mocks.confirmSchedule.mockResolvedValue({
|
|
255
|
+
documentId: 'doc-settings',
|
|
256
|
+
revision: 8,
|
|
257
|
+
state: 'armed',
|
|
258
|
+
})
|
|
259
|
+
mocks.cancelSchedule.mockResolvedValue({ schedule: null, revision: 8 })
|
|
260
|
+
mocks.copyToLocale.mockResolvedValue({
|
|
261
|
+
documentId: 'doc-settings',
|
|
262
|
+
revision: 8,
|
|
263
|
+
versionId: 'version-next',
|
|
264
|
+
})
|
|
235
265
|
|
|
236
266
|
defineAdminConfig({
|
|
237
267
|
routes: { admin: '/internal/cms' },
|
|
@@ -308,7 +338,7 @@ describe('SingletonView', () => {
|
|
|
308
338
|
expect(container.querySelector('.byline-preview-link')).toBeNull()
|
|
309
339
|
})
|
|
310
340
|
|
|
311
|
-
it('saves full data with the active locale and the
|
|
341
|
+
it('saves full data with the active locale and the observed revision or explicit empty slot', async () => {
|
|
312
342
|
render(null, 'en')
|
|
313
343
|
typeIntoTitle('First save')
|
|
314
344
|
await submit()
|
|
@@ -318,7 +348,7 @@ describe('SingletonView', () => {
|
|
|
318
348
|
singleton: 'site-settings',
|
|
319
349
|
data: { title: 'First save', attachment: undefined },
|
|
320
350
|
locale: 'en',
|
|
321
|
-
|
|
351
|
+
expectedState: 'empty',
|
|
322
352
|
},
|
|
323
353
|
})
|
|
324
354
|
expect(mocks.invalidate).toHaveBeenCalledTimes(1)
|
|
@@ -334,19 +364,28 @@ describe('SingletonView', () => {
|
|
|
334
364
|
singleton: 'site-settings',
|
|
335
365
|
data: { title: 'Second save', attachment: null },
|
|
336
366
|
locale: 'fr',
|
|
337
|
-
|
|
367
|
+
expectedRevision: 7,
|
|
338
368
|
},
|
|
339
369
|
})
|
|
340
370
|
expect(mocks.invalidate).toHaveBeenCalledTimes(2)
|
|
341
371
|
})
|
|
342
372
|
|
|
343
373
|
it('distinguishes stale saves from a singleton missing on the server', async () => {
|
|
344
|
-
mocks.update.mockRejectedValueOnce(
|
|
374
|
+
mocks.update.mockRejectedValueOnce(
|
|
375
|
+
ERR_DOCUMENT_STALE({
|
|
376
|
+
message: 'Test failure',
|
|
377
|
+
details: {
|
|
378
|
+
reason: 'revision_mismatch',
|
|
379
|
+
documentId: 'doc-settings',
|
|
380
|
+
expectedRevision: 7,
|
|
381
|
+
currentRevision: 8,
|
|
382
|
+
},
|
|
383
|
+
})
|
|
384
|
+
)
|
|
345
385
|
render(loadedDocument)
|
|
346
386
|
typeIntoTitle('Stale edit')
|
|
347
387
|
await submit()
|
|
348
|
-
|
|
349
|
-
expect(conflict).toContain('Someone else saved this singleton first')
|
|
388
|
+
expect(container.textContent).toContain('This document has changed since you opened it.')
|
|
350
389
|
|
|
351
390
|
act(() => root.unmount())
|
|
352
391
|
root = createRoot(container)
|
|
@@ -354,13 +393,22 @@ describe('SingletonView', () => {
|
|
|
354
393
|
render(loadedDocument)
|
|
355
394
|
typeIntoTitle('Missing edit')
|
|
356
395
|
await submit()
|
|
357
|
-
|
|
358
|
-
expect(
|
|
359
|
-
expect(notConfigured).not.toBe(conflict)
|
|
396
|
+
expect(container.textContent).toContain('This document is no longer available.')
|
|
397
|
+
expect(container.textContent).not.toContain('This document has changed since you opened it.')
|
|
360
398
|
})
|
|
361
399
|
|
|
362
400
|
it('keeps a failed save dirty with the navigation guard active', async () => {
|
|
363
|
-
mocks.update.mockRejectedValueOnce(
|
|
401
|
+
mocks.update.mockRejectedValueOnce(
|
|
402
|
+
ERR_DOCUMENT_STALE({
|
|
403
|
+
message: 'Test failure',
|
|
404
|
+
details: {
|
|
405
|
+
reason: 'revision_mismatch',
|
|
406
|
+
documentId: 'doc-settings',
|
|
407
|
+
expectedRevision: 7,
|
|
408
|
+
currentRevision: 8,
|
|
409
|
+
},
|
|
410
|
+
})
|
|
411
|
+
)
|
|
364
412
|
render(loadedDocument)
|
|
365
413
|
typeIntoTitle('Unsaved change')
|
|
366
414
|
await submit()
|
|
@@ -370,6 +418,26 @@ describe('SingletonView', () => {
|
|
|
370
418
|
expect(container.textContent).toContain('Cancel')
|
|
371
419
|
})
|
|
372
420
|
|
|
421
|
+
it('treats a committed afterSave failure as saved, warns, and reloads canonical state', async () => {
|
|
422
|
+
mocks.update.mockResolvedValueOnce({
|
|
423
|
+
status: 'committed-hook-failed',
|
|
424
|
+
documentId: 'doc-settings',
|
|
425
|
+
documentVersionId: 'version-next',
|
|
426
|
+
revision: 8,
|
|
427
|
+
sideEffectFailure: { phase: 'afterSave', code: 'ERR_UNHANDLED' },
|
|
428
|
+
})
|
|
429
|
+
render(loadedDocument)
|
|
430
|
+
typeIntoTitle('Committed change')
|
|
431
|
+
await submit()
|
|
432
|
+
|
|
433
|
+
expect(latestToastDescription()).toContain(
|
|
434
|
+
'Your changes were saved, but a post-save update failed'
|
|
435
|
+
)
|
|
436
|
+
expect(mocks.invalidate).toHaveBeenCalledOnce()
|
|
437
|
+
expect(mocks.guardStates.at(-1)).toBe(false)
|
|
438
|
+
expect(container.textContent).toContain('Close')
|
|
439
|
+
})
|
|
440
|
+
|
|
373
441
|
it('re-runs the loader and remounts before unlocking saved-document uploads', async () => {
|
|
374
442
|
const loader = vi
|
|
375
443
|
.fn<() => Promise<typeof loadedDocument | null>>()
|
|
@@ -13,7 +13,6 @@ import { useRouter } from '@tanstack/react-router'
|
|
|
13
13
|
import { FormRenderer } from '@byline/admin/react'
|
|
14
14
|
import type { SingletonAdminConfig, SingletonDefinition } from '@byline/core'
|
|
15
15
|
import {
|
|
16
|
-
ErrorCodes,
|
|
17
16
|
getDefaultStatus,
|
|
18
17
|
getWorkflow,
|
|
19
18
|
getWorkflowStatuses,
|
|
@@ -23,6 +22,10 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
23
22
|
import { Container, Section, useToastManager } from '@byline/ui/react'
|
|
24
23
|
|
|
25
24
|
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
25
|
+
import {
|
|
26
|
+
hasCommittedDocumentHookFailure,
|
|
27
|
+
type SerializedDocumentPublishSchedule,
|
|
28
|
+
} from '../../server-fns/collections/index.js'
|
|
26
29
|
import {
|
|
27
30
|
cancelSingletonScheduledPublish,
|
|
28
31
|
changeSingletonStatus,
|
|
@@ -34,8 +37,8 @@ import {
|
|
|
34
37
|
} from '../../server-fns/singletons/index.js'
|
|
35
38
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
36
39
|
import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
|
|
40
|
+
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
37
41
|
import { SingletonViewMenu } from './view-menu.js'
|
|
38
|
-
import type { SerializedDocumentPublishSchedule } from '../../server-fns/collections/index.js'
|
|
39
42
|
import type { ContentLocaleOption } from '../collections/view-menu.js'
|
|
40
43
|
|
|
41
44
|
type SingletonDocument = Record<string, any> & {
|
|
@@ -44,16 +47,10 @@ type SingletonDocument = Record<string, any> & {
|
|
|
44
47
|
fields: Record<string, any>
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
function errorCode(error: unknown): string | null {
|
|
48
|
-
return typeof (error as { code?: unknown })?.code === 'string'
|
|
49
|
-
? (error as { code: string }).code
|
|
50
|
-
: null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
50
|
export function SingletonView({
|
|
54
51
|
singletonDefinition,
|
|
55
52
|
adminConfig,
|
|
56
|
-
document,
|
|
53
|
+
document: loadedDocument,
|
|
57
54
|
initialData,
|
|
58
55
|
locale,
|
|
59
56
|
contentLocales,
|
|
@@ -68,6 +65,8 @@ export function SingletonView({
|
|
|
68
65
|
contentLocales: ReadonlyArray<ContentLocaleOption>
|
|
69
66
|
defaultContentLocale: string
|
|
70
67
|
}) {
|
|
68
|
+
const mutation = useDocumentMutationState(loadedDocument)
|
|
69
|
+
const document = mutation.document ?? null
|
|
71
70
|
const router = useRouter()
|
|
72
71
|
const navigate = useNavigate()
|
|
73
72
|
const toastManager = useToastManager()
|
|
@@ -119,31 +118,50 @@ export function SingletonView({
|
|
|
119
118
|
)
|
|
120
119
|
}
|
|
121
120
|
|
|
121
|
+
const notifyCommittedHookFailure = () => {
|
|
122
|
+
toast(
|
|
123
|
+
t('collections.save.hookFailedToast'),
|
|
124
|
+
t('collections.save.hookFailedDescription'),
|
|
125
|
+
'warning'
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
122
129
|
const handleSubmit = async ({ data }: { data: Record<string, any> }) => {
|
|
123
130
|
try {
|
|
124
|
-
|
|
131
|
+
mutation.assertWritable()
|
|
132
|
+
const result = await updateSingleton({
|
|
125
133
|
data: {
|
|
126
134
|
singleton: path,
|
|
127
135
|
data,
|
|
128
136
|
locale: locale ?? defaultContentLocale,
|
|
129
|
-
|
|
137
|
+
...(document == null
|
|
138
|
+
? { expectedState: 'empty' as const }
|
|
139
|
+
: { expectedRevision: mutation.expectedRevision() }),
|
|
130
140
|
},
|
|
131
141
|
})
|
|
142
|
+
mutation.adopt(result)
|
|
132
143
|
if (document != null) notifyScheduleSuspended()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
if (hasCommittedDocumentHookFailure(result)) {
|
|
145
|
+
notifyCommittedHookFailure()
|
|
146
|
+
} else {
|
|
147
|
+
toast(
|
|
148
|
+
t('singletons.edit.updateTitle', { label }),
|
|
149
|
+
t('singletons.edit.updatedDescription', { label: label.toLowerCase() }),
|
|
150
|
+
'success'
|
|
151
|
+
)
|
|
152
|
+
}
|
|
138
153
|
await reload()
|
|
139
154
|
} catch (err) {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
const handled = mutation.report(err)
|
|
156
|
+
if (handled === 'blocked') throw err
|
|
157
|
+
if (handled === 'committed') {
|
|
158
|
+
notifyCommittedHookFailure()
|
|
159
|
+
await reload()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
const description = t('singletons.edit.updateFailedDescription', {
|
|
163
|
+
label: label.toLowerCase(),
|
|
164
|
+
})
|
|
147
165
|
toast(t('singletons.edit.updateTitle', { label }), description, 'danger')
|
|
148
166
|
|
|
149
167
|
// FormRenderer commits its clean baseline only when this promise
|
|
@@ -155,7 +173,11 @@ export function SingletonView({
|
|
|
155
173
|
|
|
156
174
|
const handleStatusChange = async (status: string) => {
|
|
157
175
|
try {
|
|
158
|
-
|
|
176
|
+
mutation.assertWritable()
|
|
177
|
+
const result = await changeSingletonStatus({
|
|
178
|
+
data: { expectedRevision: mutation.expectedRevision(), singleton: path, status },
|
|
179
|
+
})
|
|
180
|
+
mutation.adopt(result)
|
|
159
181
|
toast(
|
|
160
182
|
t('collections.edit.statusUpdateTitle', { label }),
|
|
161
183
|
t('collections.edit.statusChangedDescription', { status }),
|
|
@@ -163,9 +185,18 @@ export function SingletonView({
|
|
|
163
185
|
)
|
|
164
186
|
await reload()
|
|
165
187
|
} catch (err) {
|
|
188
|
+
const handled = mutation.report(err)
|
|
189
|
+
if (handled === 'blocked') throw err
|
|
190
|
+
if (handled === 'committed') {
|
|
191
|
+
notifyCommittedHookFailure()
|
|
192
|
+
await reload()
|
|
193
|
+
return
|
|
194
|
+
}
|
|
166
195
|
toast(
|
|
167
196
|
t('collections.edit.statusUpdateTitle', { label }),
|
|
168
|
-
t('collections.edit.statusChangeFailedDescription', {
|
|
197
|
+
t('collections.edit.statusChangeFailedDescription', {
|
|
198
|
+
message: t('documentConcurrency.failed'),
|
|
199
|
+
}),
|
|
169
200
|
'danger'
|
|
170
201
|
)
|
|
171
202
|
throw err
|
|
@@ -174,7 +205,11 @@ export function SingletonView({
|
|
|
174
205
|
|
|
175
206
|
const handleUnpublish = async () => {
|
|
176
207
|
try {
|
|
177
|
-
|
|
208
|
+
mutation.assertWritable()
|
|
209
|
+
const result = await unpublishSingleton({
|
|
210
|
+
data: { expectedRevision: mutation.expectedRevision(), singleton: path },
|
|
211
|
+
})
|
|
212
|
+
mutation.adopt(result)
|
|
178
213
|
toast(
|
|
179
214
|
t('collections.edit.unpublishTitle', { label }),
|
|
180
215
|
t('collections.edit.unpublishedDescription'),
|
|
@@ -182,9 +217,18 @@ export function SingletonView({
|
|
|
182
217
|
)
|
|
183
218
|
await reload()
|
|
184
219
|
} catch (err) {
|
|
220
|
+
const handled = mutation.report(err)
|
|
221
|
+
if (handled === 'blocked') throw err
|
|
222
|
+
if (handled === 'committed') {
|
|
223
|
+
notifyCommittedHookFailure()
|
|
224
|
+
await reload()
|
|
225
|
+
return
|
|
226
|
+
}
|
|
185
227
|
toast(
|
|
186
228
|
t('collections.edit.unpublishTitle', { label }),
|
|
187
|
-
t('collections.edit.unpublishFailedDescription', {
|
|
229
|
+
t('collections.edit.unpublishFailedDescription', {
|
|
230
|
+
message: t('documentConcurrency.failed'),
|
|
231
|
+
}),
|
|
188
232
|
'danger'
|
|
189
233
|
)
|
|
190
234
|
throw err
|
|
@@ -199,27 +243,34 @@ export function SingletonView({
|
|
|
199
243
|
overwrite: boolean
|
|
200
244
|
}) => {
|
|
201
245
|
try {
|
|
202
|
-
|
|
246
|
+
mutation.assertWritable()
|
|
247
|
+
const result = await copySingletonToLocale({
|
|
203
248
|
data: {
|
|
249
|
+
expectedRevision: mutation.expectedRevision(),
|
|
204
250
|
singleton: path,
|
|
205
251
|
sourceLocale: locale ?? defaultContentLocale,
|
|
206
252
|
targetLocale,
|
|
207
253
|
overwrite,
|
|
208
254
|
},
|
|
209
255
|
})
|
|
256
|
+
mutation.adopt(result)
|
|
210
257
|
const sourceLocale = locale ?? defaultContentLocale
|
|
211
258
|
const sourceLabel =
|
|
212
259
|
contentLocales.find((entry) => entry.code === sourceLocale)?.label ?? sourceLocale
|
|
213
260
|
const targetLabel =
|
|
214
261
|
contentLocales.find((entry) => entry.code === targetLocale)?.label ?? targetLocale
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
262
|
+
if (hasCommittedDocumentHookFailure(result)) {
|
|
263
|
+
notifyCommittedHookFailure()
|
|
264
|
+
} else {
|
|
265
|
+
toast(
|
|
266
|
+
t('collections.edit.copyToLocaleTitle', { label }),
|
|
267
|
+
t('collections.edit.copiedSuccessMessage', {
|
|
268
|
+
source: sourceLabel,
|
|
269
|
+
target: targetLabel,
|
|
270
|
+
}),
|
|
271
|
+
'success'
|
|
272
|
+
)
|
|
273
|
+
}
|
|
223
274
|
notifyScheduleSuspended()
|
|
224
275
|
navigate({
|
|
225
276
|
to: getAdminRoutePath('singletons', '$singleton'),
|
|
@@ -227,9 +278,16 @@ export function SingletonView({
|
|
|
227
278
|
search: { locale: targetLocale },
|
|
228
279
|
})
|
|
229
280
|
} catch (err) {
|
|
281
|
+
const handled = mutation.report(err)
|
|
282
|
+
if (handled === 'blocked') throw err
|
|
283
|
+
if (handled === 'committed') {
|
|
284
|
+
notifyCommittedHookFailure()
|
|
285
|
+
await reload()
|
|
286
|
+
return
|
|
287
|
+
}
|
|
230
288
|
toast(
|
|
231
289
|
t('collections.edit.copyToLocaleTitle', { label }),
|
|
232
|
-
t('collections.edit.copyFailedDescription', { message: (
|
|
290
|
+
t('collections.edit.copyFailedDescription', { message: t('documentConcurrency.failed') }),
|
|
233
291
|
'danger'
|
|
234
292
|
)
|
|
235
293
|
throw err
|
|
@@ -238,9 +296,16 @@ export function SingletonView({
|
|
|
238
296
|
|
|
239
297
|
const handleSchedulePublication = async ({ publishAt }: { publishAt: string }) => {
|
|
240
298
|
try {
|
|
241
|
-
|
|
242
|
-
|
|
299
|
+
mutation.assertWritable()
|
|
300
|
+
const result = await scheduleSingletonPublish({
|
|
301
|
+
data: {
|
|
302
|
+
expectedRevision: mutation.expectedRevision(),
|
|
303
|
+
singleton: path,
|
|
304
|
+
publishAt,
|
|
305
|
+
expectedVersionId: String(document?.versionId),
|
|
306
|
+
},
|
|
243
307
|
})
|
|
308
|
+
mutation.adopt(result)
|
|
244
309
|
toast(
|
|
245
310
|
t('scheduledPublication.toast.scheduledTitle'),
|
|
246
311
|
t('scheduledPublication.toast.scheduledDescription'),
|
|
@@ -248,16 +313,29 @@ export function SingletonView({
|
|
|
248
313
|
)
|
|
249
314
|
await reload()
|
|
250
315
|
} catch (err) {
|
|
251
|
-
|
|
316
|
+
const handled = mutation.report(err)
|
|
317
|
+
if (handled === 'blocked') throw err
|
|
318
|
+
if (handled === 'committed') {
|
|
319
|
+
notifyCommittedHookFailure()
|
|
320
|
+
await reload()
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
|
|
252
324
|
throw err
|
|
253
325
|
}
|
|
254
326
|
}
|
|
255
327
|
|
|
256
328
|
const handleConfirmScheduledPublication = async () => {
|
|
257
329
|
try {
|
|
258
|
-
|
|
259
|
-
|
|
330
|
+
mutation.assertWritable()
|
|
331
|
+
const result = await confirmSingletonScheduledPublish({
|
|
332
|
+
data: {
|
|
333
|
+
expectedRevision: mutation.expectedRevision(),
|
|
334
|
+
singleton: path,
|
|
335
|
+
expectedVersionId: String(document?.versionId),
|
|
336
|
+
},
|
|
260
337
|
})
|
|
338
|
+
mutation.adopt(result)
|
|
261
339
|
toast(
|
|
262
340
|
t('scheduledPublication.toast.confirmedTitle'),
|
|
263
341
|
t('scheduledPublication.toast.confirmedDescription'),
|
|
@@ -265,14 +343,25 @@ export function SingletonView({
|
|
|
265
343
|
)
|
|
266
344
|
await reload()
|
|
267
345
|
} catch (err) {
|
|
268
|
-
|
|
346
|
+
const handled = mutation.report(err)
|
|
347
|
+
if (handled === 'blocked') throw err
|
|
348
|
+
if (handled === 'committed') {
|
|
349
|
+
notifyCommittedHookFailure()
|
|
350
|
+
await reload()
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
|
|
269
354
|
throw err
|
|
270
355
|
}
|
|
271
356
|
}
|
|
272
357
|
|
|
273
358
|
const handleCancelScheduledPublication = async () => {
|
|
274
359
|
try {
|
|
275
|
-
|
|
360
|
+
mutation.assertWritable()
|
|
361
|
+
const result = await cancelSingletonScheduledPublish({
|
|
362
|
+
data: { expectedRevision: mutation.expectedRevision(), singleton: path },
|
|
363
|
+
})
|
|
364
|
+
mutation.adopt({ ...result, documentId: String(document?.id) })
|
|
276
365
|
toast(
|
|
277
366
|
t('scheduledPublication.toast.cancelledTitle'),
|
|
278
367
|
t('scheduledPublication.toast.cancelledDescription'),
|
|
@@ -280,7 +369,14 @@ export function SingletonView({
|
|
|
280
369
|
)
|
|
281
370
|
await reload()
|
|
282
371
|
} catch (err) {
|
|
283
|
-
|
|
372
|
+
const handled = mutation.report(err)
|
|
373
|
+
if (handled === 'blocked') throw err
|
|
374
|
+
if (handled === 'committed') {
|
|
375
|
+
notifyCommittedHookFailure()
|
|
376
|
+
await reload()
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
toast(t('scheduledPublication.toast.failedTitle'), t('documentConcurrency.failed'), 'danger')
|
|
284
380
|
throw err
|
|
285
381
|
}
|
|
286
382
|
}
|
|
@@ -289,6 +385,10 @@ export function SingletonView({
|
|
|
289
385
|
<Section>
|
|
290
386
|
<Container>
|
|
291
387
|
<FormRenderer
|
|
388
|
+
mutationIssue={mutation.issue}
|
|
389
|
+
mutationsBlocked={mutation.blocked}
|
|
390
|
+
observedRevision={mutation.revision}
|
|
391
|
+
onMutationError={mutation.report}
|
|
292
392
|
mode={document == null ? 'create' : 'edit'}
|
|
293
393
|
fields={fields}
|
|
294
394
|
onSubmit={handleSubmit}
|
|
@@ -44,12 +44,11 @@ const uploadField: UploadFieldFn = (collection, formData, createDocument) =>
|
|
|
44
44
|
serverUploadField(collection, formData, createDocument)
|
|
45
45
|
|
|
46
46
|
const placeTreeNode: PlaceTreeNodeFn = async (input) => {
|
|
47
|
-
|
|
48
|
-
return { orderKey }
|
|
47
|
+
return serverPlaceTreeNode({ data: input })
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
const removeFromTree: RemoveFromTreeFn = async (input) => {
|
|
52
|
-
|
|
51
|
+
return serverRemoveFromTree({ data: input })
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
const getTreeAncestors: GetTreeAncestorsFn = (input) =>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseDocumentRevision } from '@byline/core'
|
|
1
2
|
/**
|
|
2
3
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
4
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -177,8 +178,13 @@ export function createCollectionListRoute(path: string) {
|
|
|
177
178
|
beforeDocumentId,
|
|
178
179
|
afterDocumentId,
|
|
179
180
|
}) => {
|
|
180
|
-
await placeTreeNode({
|
|
181
|
+
return await placeTreeNode({
|
|
181
182
|
data: {
|
|
183
|
+
expectedRevision: parseDocumentRevision(
|
|
184
|
+
(data as { rows: CollectionTreeRow[] }).rows.find(
|
|
185
|
+
(row) => row.id === documentId
|
|
186
|
+
)?.revision
|
|
187
|
+
),
|
|
182
188
|
collection,
|
|
183
189
|
documentId,
|
|
184
190
|
parentDocumentId,
|
|
@@ -199,8 +205,13 @@ export function createCollectionListRoute(path: string) {
|
|
|
199
205
|
orderable={collectionDef.orderable === true}
|
|
200
206
|
listActions={adminConfig?.listActions}
|
|
201
207
|
onReorder={async ({ documentId, beforeDocumentId, afterDocumentId }) => {
|
|
202
|
-
await reorderCollectionDocument({
|
|
208
|
+
return await reorderCollectionDocument({
|
|
203
209
|
data: {
|
|
210
|
+
expectedRevision: parseDocumentRevision(
|
|
211
|
+
data.docs.find(
|
|
212
|
+
(row: { id: string; revision?: number }) => row.id === documentId
|
|
213
|
+
)?.revision
|
|
214
|
+
),
|
|
204
215
|
collection,
|
|
205
216
|
documentId,
|
|
206
217
|
beforeDocumentId,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { testAdapter } from '../../../core/src/storage/db-adapter.test-helper.js'
|
|
1
2
|
/**
|
|
2
3
|
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
4
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
@@ -61,7 +62,7 @@ vi.mock('@byline/core', () => ({
|
|
|
61
62
|
getServerConfig: () => ({
|
|
62
63
|
i18n: { content: { defaultLocale: 'en' } },
|
|
63
64
|
scheduledPublication: { enabled: mocks.scheduledPublicationEnabled },
|
|
64
|
-
db: { queries: { documents: { getPublishedVersion: mocks.getPublishedVersion } } },
|
|
65
|
+
db: testAdapter({ queries: { documents: { getPublishedVersion: mocks.getPublishedVersion } } }),
|
|
65
66
|
}),
|
|
66
67
|
getSingletonAdminConfig: () => null,
|
|
67
68
|
}))
|
|
@@ -112,6 +113,7 @@ describe('singleton editor route loader', () => {
|
|
|
112
113
|
|
|
113
114
|
await expect(load('fr')).resolves.toEqual({
|
|
114
115
|
document: null,
|
|
116
|
+
expectedEmpty: true,
|
|
115
117
|
initialData: { title: 'Default title' },
|
|
116
118
|
})
|
|
117
119
|
expect(mocks.read).toHaveBeenCalledWith({
|
|
@@ -130,6 +132,12 @@ describe('singleton editor route loader', () => {
|
|
|
130
132
|
versionId: 'version-current',
|
|
131
133
|
status: 'draft',
|
|
132
134
|
fields: { title: 'Loaded' },
|
|
135
|
+
revision: 7,
|
|
136
|
+
scheduledPublication: {
|
|
137
|
+
state: 'armed',
|
|
138
|
+
publishAt: '2026-09-01T00:00:00.000Z',
|
|
139
|
+
targetVersionId: 'version-current',
|
|
140
|
+
},
|
|
133
141
|
})
|
|
134
142
|
mocks.getPublishedVersion.mockResolvedValue({
|
|
135
143
|
document_id: 'document-settings',
|
|
@@ -167,6 +175,6 @@ describe('singleton editor route loader', () => {
|
|
|
167
175
|
collection_id: 'collection-settings',
|
|
168
176
|
document_id: 'document-settings',
|
|
169
177
|
})
|
|
170
|
-
expect(mocks.getScheduledPublish).
|
|
178
|
+
expect(mocks.getScheduledPublish).not.toHaveBeenCalled()
|
|
171
179
|
})
|
|
172
180
|
})
|
|
@@ -10,7 +10,7 @@ import { createFileRoute, notFound } from '@tanstack/react-router'
|
|
|
10
10
|
import { createServerFn } from '@tanstack/react-start'
|
|
11
11
|
|
|
12
12
|
import type { DocumentPublishScheduleInfo } from '@byline/client'
|
|
13
|
-
import {
|
|
13
|
+
import { getAdminRequestContext } from '@byline/client/server'
|
|
14
14
|
import {
|
|
15
15
|
buildInitialDataFromFields,
|
|
16
16
|
documentAbilityKey,
|
|
@@ -51,6 +51,7 @@ export const getSingletonRouteDocument = createServerFn({ method: 'GET' })
|
|
|
51
51
|
if (document == null) {
|
|
52
52
|
return {
|
|
53
53
|
document: null,
|
|
54
|
+
expectedEmpty: true,
|
|
54
55
|
initialData: serialise(
|
|
55
56
|
await buildInitialDataFromFields(definition.fields, {
|
|
56
57
|
locale: data.locale ?? getServerConfig().i18n.content.defaultLocale,
|
|
@@ -93,9 +94,9 @@ export const getSingletonRouteDocument = createServerFn({ method: 'GET' })
|
|
|
93
94
|
actor?.hasAbility(documentAbilityKey(definition, 'changeStatus')) === true &&
|
|
94
95
|
actor.hasAbility(documentAbilityKey(definition, 'publish'))
|
|
95
96
|
if (canSchedulePublication) {
|
|
96
|
-
scheduledPublish =
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
scheduledPublish = (
|
|
98
|
+
document as unknown as { scheduledPublication: DocumentPublishScheduleInfo | null }
|
|
99
|
+
).scheduledPublication
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
const serializedSchedule = scheduledPublish == null ? null : serialise(scheduledPublish)
|
|
@@ -159,6 +160,7 @@ export function createSingletonRoute(path: string) {
|
|
|
159
160
|
]}
|
|
160
161
|
/>
|
|
161
162
|
<SingletonView
|
|
163
|
+
key={`${singleton}/${locale}`}
|
|
162
164
|
singletonDefinition={definition}
|
|
163
165
|
adminConfig={getSingletonAdminConfig(singleton) ?? undefined}
|
|
164
166
|
document={routeState.document}
|