@byline/host-tanstack-start 4.17.0 → 4.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/admin-shell/chrome/router-pager.d.ts +7 -0
  2. package/dist/admin-shell/chrome/router-pager.js +13 -0
  3. package/dist/admin-shell/chrome/scroll-to-top.d.ts +20 -0
  4. package/dist/admin-shell/chrome/scroll-to-top.js +9 -0
  5. package/dist/admin-shell/collections/create.js +22 -5
  6. package/dist/admin-shell/collections/edit.js +85 -17
  7. package/dist/admin-shell/collections/restore-version-modal.js +14 -2
  8. package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
  9. package/dist/admin-shell/singletons/view.js +10 -4
  10. package/dist/server-fns/collections/copy-to-locale.d.ts +2 -1
  11. package/dist/server-fns/collections/copy-to-locale.js +13 -6
  12. package/dist/server-fns/collections/create.d.ts +1 -1
  13. package/dist/server-fns/collections/create.js +15 -7
  14. package/dist/server-fns/collections/delete-locale.d.ts +2 -1
  15. package/dist/server-fns/collections/delete-locale.js +11 -4
  16. package/dist/server-fns/collections/duplicate.d.ts +2 -1
  17. package/dist/server-fns/collections/duplicate.js +10 -3
  18. package/dist/server-fns/collections/index.d.ts +1 -0
  19. package/dist/server-fns/collections/index.js +1 -0
  20. package/dist/server-fns/collections/restore-version.d.ts +1 -1
  21. package/dist/server-fns/collections/restore-version.js +17 -10
  22. package/dist/server-fns/collections/save-outcome.d.ts +23 -0
  23. package/dist/server-fns/collections/save-outcome.js +18 -0
  24. package/dist/server-fns/collections/update.d.ts +3 -1
  25. package/dist/server-fns/collections/update.js +17 -7
  26. package/dist/server-fns/singletons/copy-to-locale.js +14 -5
  27. package/dist/server-fns/singletons/restore-version.js +10 -1
  28. package/dist/server-fns/singletons/update.js +12 -5
  29. package/package.json +10 -10
  30. package/src/admin-shell/chrome/router-pager.tsx +22 -0
  31. package/src/admin-shell/chrome/scroll-to-top.test.ts +56 -0
  32. package/src/admin-shell/chrome/scroll-to-top.ts +28 -0
  33. package/src/admin-shell/collections/create.tsx +22 -3
  34. package/src/admin-shell/collections/edit.tsx +63 -10
  35. package/src/admin-shell/collections/restore-version-modal.tsx +11 -2
  36. package/src/admin-shell/collections/save-navigation.test.tsx +384 -0
  37. package/src/admin-shell/singletons/history.test.tsx +43 -0
  38. package/src/admin-shell/singletons/view.test.tsx +27 -0
  39. package/src/admin-shell/singletons/view.tsx +35 -16
  40. package/src/server-fns/collections/copy-to-locale.ts +43 -29
  41. package/src/server-fns/collections/create.ts +16 -8
  42. package/src/server-fns/collections/delete-locale.ts +38 -24
  43. package/src/server-fns/collections/duplicate.ts +38 -24
  44. package/src/server-fns/collections/index.ts +1 -0
  45. package/src/server-fns/collections/restore-version.ts +16 -9
  46. package/src/server-fns/collections/save-outcome.test.node.ts +77 -0
  47. package/src/server-fns/collections/save-outcome.ts +55 -0
  48. package/src/server-fns/collections/update.ts +23 -8
  49. package/src/server-fns/singletons/copy-to-locale.ts +16 -9
  50. package/src/server-fns/singletons/restore-version.ts +12 -3
  51. package/src/server-fns/singletons/singletons.test.node.ts +34 -0
  52. package/src/server-fns/singletons/update.ts +12 -5
@@ -3,6 +3,7 @@ import { getAdminRequestContext } from "@byline/client/server";
3
3
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from "@byline/core";
4
4
  import { restoreDocumentVersion } from "@byline/core/services";
5
5
  import { ensureCollection } from "../../integrations/api-utils.js";
6
+ import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
6
7
  const restore_version_restoreDocumentVersion = createServerFn({
7
8
  method: 'POST'
8
9
  }).validator((input)=>input).handler(async ({ data: input })=>{
@@ -27,15 +28,21 @@ const restore_version_restoreDocumentVersion = createServerFn({
27
28
  slugifier: serverConfig.slugifier,
28
29
  requestContext: await getAdminRequestContext()
29
30
  };
30
- const result = await restoreDocumentVersion(ctx, {
31
- documentId: id,
32
- sourceVersionId: versionId
33
- });
34
- return {
35
- status: 'ok',
36
- documentId: result.documentId,
37
- documentVersionId: result.documentVersionId,
38
- sourceVersionId: result.sourceVersionId
39
- };
31
+ try {
32
+ const result = await restoreDocumentVersion(ctx, {
33
+ documentId: id,
34
+ sourceVersionId: versionId
35
+ });
36
+ return {
37
+ status: 'ok',
38
+ documentId: result.documentId,
39
+ documentVersionId: result.documentVersionId,
40
+ sourceVersionId: result.sourceVersionId
41
+ };
42
+ } catch (error) {
43
+ const committedFailure = toCommittedDocumentHookFailureResponse(error);
44
+ if (null != committedFailure) return committedFailure;
45
+ throw error;
46
+ }
40
47
  });
41
48
  export { restore_version_restoreDocumentVersion as restoreDocumentVersion };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+ import { type DocumentHookCommittedPhase, type DocumentHookSideEffectCode } from '@byline/core/services';
9
+ export interface CollectionDocumentCommittedHookFailureResponse {
10
+ status: 'committed-hook-failed';
11
+ documentId: string;
12
+ documentVersionId: string;
13
+ sideEffectFailure: {
14
+ phase: DocumentHookCommittedPhase;
15
+ code: DocumentHookSideEffectCode;
16
+ };
17
+ }
18
+ /**
19
+ * Convert only validated core committed-hook errors into safe wire data. Raw
20
+ * hook errors, messages, stack traces, and storage paths remain server-side.
21
+ */
22
+ export declare function toCommittedDocumentHookFailureResponse(error: unknown): CollectionDocumentCommittedHookFailureResponse | null;
23
+ export declare function hasCommittedDocumentHookFailure(result: unknown): result is CollectionDocumentCommittedHookFailureResponse;
@@ -0,0 +1,18 @@
1
+ import { getDocumentHookCommittedDetails } from "@byline/core/services";
2
+ function toCommittedDocumentHookFailureResponse(error) {
3
+ const details = getDocumentHookCommittedDetails(error);
4
+ if (null == details) return null;
5
+ return {
6
+ status: 'committed-hook-failed',
7
+ documentId: details.documentId,
8
+ documentVersionId: details.documentVersionId,
9
+ sideEffectFailure: {
10
+ phase: details.phase,
11
+ code: details.sideEffectCode
12
+ }
13
+ };
14
+ }
15
+ function hasCommittedDocumentHookFailure(result) {
16
+ return 'object' == typeof result && null != result && 'status' in result && 'committed-hook-failed' === result.status;
17
+ }
18
+ export { hasCommittedDocumentHookFailure, toCommittedDocumentHookFailureResponse };
@@ -18,8 +18,10 @@ export declare const updateCollectionDocumentWithPatches: import("@tanstack/reac
18
18
  patches: DocumentPatch[];
19
19
  versionId?: string;
20
20
  locale?: string;
21
- }, Promise<{
21
+ }, Promise<import("./save-outcome.js").CollectionDocumentCommittedHookFailureResponse | {
22
22
  status: 'ok';
23
+ documentId: string;
24
+ documentVersionId: string;
23
25
  }>>;
24
26
  export declare const updateCollectionDocumentSystemFields: import("@tanstack/react-start").RequiredFetcher<undefined, (input: {
25
27
  collection: string;
@@ -3,6 +3,7 @@ import { getAdminRequestContext } from "@byline/client/server";
3
3
  import { ERR_NOT_FOUND, getLogger, getServerConfig } from "@byline/core";
4
4
  import { updateDocumentSystemFields, updateDocumentWithPatches } from "@byline/core/services";
5
5
  import { ensureCollection } from "../../integrations/api-utils.js";
6
+ import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
6
7
  const updateCollectionDocumentWithPatches = createServerFn({
7
8
  method: 'POST'
8
9
  }).validator((input)=>input).handler(async ({ data: input })=>{
@@ -27,14 +28,23 @@ const updateCollectionDocumentWithPatches = createServerFn({
27
28
  slugifier: serverConfig.slugifier,
28
29
  requestContext: await getAdminRequestContext()
29
30
  };
30
- await updateDocumentWithPatches(ctx, {
31
- documentId: id,
32
- patches,
33
- documentVersionId: versionId,
34
- locale: locale ?? serverConfig.i18n.content.defaultLocale
35
- });
31
+ let result;
32
+ try {
33
+ result = await updateDocumentWithPatches(ctx, {
34
+ documentId: id,
35
+ patches,
36
+ documentVersionId: versionId,
37
+ locale: locale ?? serverConfig.i18n.content.defaultLocale
38
+ });
39
+ } catch (error) {
40
+ const committedFailure = toCommittedDocumentHookFailureResponse(error);
41
+ if (null != committedFailure) return committedFailure;
42
+ throw error;
43
+ }
36
44
  return {
37
- status: 'ok'
45
+ status: 'ok',
46
+ documentId: result.documentId,
47
+ documentVersionId: result.documentVersionId
38
48
  };
39
49
  });
40
50
  const updateCollectionDocumentSystemFields = createServerFn({
@@ -1,11 +1,20 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
2
  import { getAdminBylineClient } from "@byline/client/server";
3
+ import { toCommittedDocumentHookFailureResponse } from "../collections/save-outcome.js";
3
4
  import { serialise } from "../serialise.js";
4
5
  const copySingletonToLocale = createServerFn({
5
6
  method: 'POST'
6
- }).validator((input)=>input).handler(async ({ data })=>serialise(await getAdminBylineClient().singleton(data.singleton).copyToLocale({
7
- sourceLocale: data.sourceLocale,
8
- targetLocale: data.targetLocale,
9
- overwrite: data.overwrite
10
- })));
7
+ }).validator((input)=>input).handler(async ({ data })=>{
8
+ try {
9
+ return serialise(await getAdminBylineClient().singleton(data.singleton).copyToLocale({
10
+ sourceLocale: data.sourceLocale,
11
+ targetLocale: data.targetLocale,
12
+ overwrite: data.overwrite
13
+ }));
14
+ } catch (error) {
15
+ const committedFailure = toCommittedDocumentHookFailureResponse(error);
16
+ if (null != committedFailure) return committedFailure;
17
+ throw error;
18
+ }
19
+ });
11
20
  export { copySingletonToLocale };
@@ -1,7 +1,16 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
2
  import { getAdminBylineClient } from "@byline/client/server";
3
+ import { toCommittedDocumentHookFailureResponse } from "../collections/save-outcome.js";
3
4
  import { serialise } from "../serialise.js";
4
5
  const restoreSingletonVersion = createServerFn({
5
6
  method: 'POST'
6
- }).validator((input)=>input).handler(async ({ data })=>serialise(await getAdminBylineClient().singleton(data.singleton).restoreVersion(data.versionId)));
7
+ }).validator((input)=>input).handler(async ({ data })=>{
8
+ try {
9
+ return serialise(await getAdminBylineClient().singleton(data.singleton).restoreVersion(data.versionId));
10
+ } catch (error) {
11
+ const committedFailure = toCommittedDocumentHookFailureResponse(error);
12
+ if (null != committedFailure) return committedFailure;
13
+ throw error;
14
+ }
15
+ });
7
16
  export { restoreSingletonVersion };
@@ -1,14 +1,21 @@
1
1
  import { createServerFn } from "@tanstack/react-start";
2
2
  import { getAdminBylineClient } from "@byline/client/server";
3
+ import { toCommittedDocumentHookFailureResponse } from "../collections/save-outcome.js";
3
4
  import { serialise } from "../serialise.js";
4
5
  const updateSingleton = createServerFn({
5
6
  method: 'POST'
6
7
  }).validator((input)=>input).handler(async ({ data })=>{
7
8
  const { singleton, data: fields, locale, expectedVersionId } = data;
8
- const result = await getAdminBylineClient().singleton(singleton).update(fields, {
9
- locale,
10
- expectedVersionId
11
- });
12
- return serialise(result);
9
+ try {
10
+ const result = await getAdminBylineClient().singleton(singleton).update(fields, {
11
+ locale,
12
+ expectedVersionId
13
+ });
14
+ return serialise(result);
15
+ } catch (error) {
16
+ const committedFailure = toCommittedDocumentHookFailureResponse(error);
17
+ if (null != committedFailure) return committedFailure;
18
+ throw error;
19
+ }
13
20
  });
14
21
  export { updateSingleton };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "4.17.0",
6
+ "version": "4.19.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -125,15 +125,15 @@
125
125
  "react-json-view-lite": "^2.5.0",
126
126
  "react-swipeable": "^7.0.2",
127
127
  "zod": "^4.4.3",
128
- "@byline/analytics": "4.17.0",
129
- "@byline/ai": "4.17.0",
130
- "@byline/auth": "4.17.0",
131
- "@byline/analytics-agent": "4.17.0",
132
- "@byline/i18n": "4.17.0",
133
- "@byline/admin": "4.17.0",
134
- "@byline/core": "4.17.0",
135
- "@byline/ui": "4.17.0",
136
- "@byline/client": "4.17.0"
128
+ "@byline/admin": "4.19.0",
129
+ "@byline/analytics": "4.19.0",
130
+ "@byline/analytics-agent": "4.19.0",
131
+ "@byline/ai": "4.19.0",
132
+ "@byline/core": "4.19.0",
133
+ "@byline/client": "4.19.0",
134
+ "@byline/auth": "4.19.0",
135
+ "@byline/i18n": "4.19.0",
136
+ "@byline/ui": "4.19.0"
137
137
  },
138
138
  "peerDependencies": {
139
139
  "@tanstack/react-router": "^1.167.0",
@@ -6,6 +6,7 @@
6
6
  * Copyright (c) Infonomic Company Limited
7
7
  */
8
8
 
9
+ import { useCallback } from 'react'
9
10
  import { useRouterState } from '@tanstack/react-router'
10
11
 
11
12
  import type { PaginationProps } from '@byline/ui/react'
@@ -18,6 +19,7 @@ import {
18
19
  } from '@byline/ui/react'
19
20
 
20
21
  import { Link } from './loose-router.js'
22
+ import { scrollWindowToTop } from './scroll-to-top.js'
21
23
 
22
24
  interface RouterPageProps extends PaginationProps {
23
25
  smoothScrollToTop?: boolean
@@ -27,6 +29,13 @@ interface RouterPageProps extends PaginationProps {
27
29
  * A convenience pager wrapped around uikit's Pagination with `asChild`
28
30
  * targets that delegate to TanStack Router's `<Link>`. `asChild` merges
29
31
  * the existing button styles into the rendered Link.
32
+ *
33
+ * `smoothScrollToTop` is for a pager sitting *below* a long list: turning a
34
+ * page there should carry the reader back to the top rather than leaving them
35
+ * at the foot of a fresh page. It suppresses the router's own instant
36
+ * `resetScroll` and animates instead — so the component owes an actual
37
+ * scroll, which was the missing half: the flag disabled the reset and put
38
+ * nothing in its place, so a bottom pager did not scroll at all.
30
39
  */
31
40
  export function RouterPager({
32
41
  className,
@@ -36,6 +45,14 @@ export function RouterPager({
36
45
  }: RouterPageProps): React.JSX.Element {
37
46
  const location = useRouterState({ select: (s) => s.location })
38
47
 
48
+ // Fires alongside navigation rather than after it: the scroll and the new
49
+ // page load run together, so the list is in place by the time the reader
50
+ // arrives at the top.
51
+ const handleScrollToTop = useCallback(() => {
52
+ if (smoothScrollToTop !== true) return
53
+ scrollWindowToTop()
54
+ }, [smoothScrollToTop])
55
+
39
56
  return (
40
57
  <Pagination variant="dashboard" {...rest}>
41
58
  <Pagination.Root className={className} ariaLabel={ariaLabel}>
@@ -51,6 +68,7 @@ export function RouterPager({
51
68
  ) : (
52
69
  <Link
53
70
  resetScroll={smoothScrollToTop !== true}
71
+ onClick={handleScrollToTop}
54
72
  to={location.pathname as never}
55
73
  search={params}
56
74
  />
@@ -76,6 +94,7 @@ export function RouterPager({
76
94
  ) : (
77
95
  <Link
78
96
  resetScroll={smoothScrollToTop !== true}
97
+ onClick={handleScrollToTop}
79
98
  to={location.pathname as never}
80
99
  search={params}
81
100
  />
@@ -103,6 +122,7 @@ export function RouterPager({
103
122
  ) : (
104
123
  <Link
105
124
  resetScroll={smoothScrollToTop !== true}
125
+ onClick={handleScrollToTop}
106
126
  to={location.pathname as never}
107
127
  search={params}
108
128
  />
@@ -130,6 +150,7 @@ export function RouterPager({
130
150
  ) : (
131
151
  <Link
132
152
  resetScroll={smoothScrollToTop !== true}
153
+ onClick={handleScrollToTop}
133
154
  to={location.pathname as never}
134
155
  search={params}
135
156
  />
@@ -156,6 +177,7 @@ export function RouterPager({
156
177
  ) : (
157
178
  <Link
158
179
  resetScroll={smoothScrollToTop !== true}
180
+ onClick={handleScrollToTop}
159
181
  to={location.pathname as never}
160
182
  search={params}
161
183
  />
@@ -0,0 +1,56 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+
11
+ import { scrollWindowToTop } from './scroll-to-top.js'
12
+
13
+ function mockReducedMotion(matches: boolean) {
14
+ vi.stubGlobal(
15
+ 'matchMedia',
16
+ vi.fn((query: string) => ({ matches, media: query })) as unknown as typeof window.matchMedia
17
+ )
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.unstubAllGlobals()
22
+ vi.restoreAllMocks()
23
+ })
24
+
25
+ describe('scrollWindowToTop', () => {
26
+ it('scrolls to the top of the page', () => {
27
+ const scrollTo = vi.fn()
28
+ vi.stubGlobal('scrollTo', scrollTo)
29
+ mockReducedMotion(false)
30
+
31
+ scrollWindowToTop()
32
+
33
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
34
+ })
35
+
36
+ it('skips the animation when the reader asks for reduced motion', () => {
37
+ const scrollTo = vi.fn()
38
+ vi.stubGlobal('scrollTo', scrollTo)
39
+ mockReducedMotion(true)
40
+
41
+ scrollWindowToTop()
42
+
43
+ // Still goes to the top — only the travel is dropped.
44
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'auto' })
45
+ })
46
+
47
+ /** Older browsers and some test environments have no `matchMedia`. */
48
+ it('still scrolls when matchMedia is unavailable', () => {
49
+ const scrollTo = vi.fn()
50
+ vi.stubGlobal('scrollTo', scrollTo)
51
+ vi.stubGlobal('matchMedia', undefined)
52
+
53
+ expect(() => scrollWindowToTop()).not.toThrow()
54
+ expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' })
55
+ })
56
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Return the reader to the top of the page.
11
+ *
12
+ * Separate from `RouterPager` so the behaviour can be asserted without
13
+ * mounting a router. `smoothScrollToTop` suppresses TanStack's instant
14
+ * `resetScroll` in favour of an animated one, which makes a missing
15
+ * implementation silent — the page simply does not move. That is what
16
+ * happened to every pager carrying the flag.
17
+ *
18
+ * Honours `prefers-reduced-motion`: those readers still arrive at the top,
19
+ * without the travel.
20
+ */
21
+ export function scrollWindowToTop(): void {
22
+ if (typeof window === 'undefined') return
23
+
24
+ // `matchMedia` is absent in some test and server-ish environments; treating
25
+ // that as "no preference expressed" keeps the scroll working either way.
26
+ const prefersReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
27
+ window.scrollTo({ top: 0, behavior: prefersReducedMotion === true ? 'auto' : 'smooth' })
28
+ }
@@ -15,7 +15,10 @@ import { Container, Section, useToastManager } from '@byline/ui/react'
15
15
 
16
16
  import { getAdminRoutePath } from '../../routes/admin-path.js'
17
17
  import { decodeListReturnState } from '../../routes/list-return-state.js'
18
- import { createCollectionDocument } from '../../server-fns/collections/index.js'
18
+ import {
19
+ createCollectionDocument,
20
+ hasCommittedDocumentHookFailure,
21
+ } from '../../server-fns/collections/index.js'
19
22
  import { useNavigate } from '../chrome/loose-router.js'
20
23
  import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
21
24
 
@@ -65,6 +68,14 @@ export const CreateView = ({
65
68
  ...(systemAvailableLocales ? { availableLocales: systemAvailableLocales } : {}),
66
69
  },
67
70
  })
71
+ const hookFailed = hasCommittedDocumentHookFailure(result)
72
+ if (hookFailed) {
73
+ toastManager.add({
74
+ title: t('collections.save.hookFailedToast'),
75
+ description: t('collections.save.hookFailedDescription'),
76
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
77
+ })
78
+ }
68
79
  // Create → edit: land the editor on the new document, where the rest
69
80
  // of the work (content, status, relations) happens. `from` threads
70
81
  // forward so closing the brand-new document also returns to the
@@ -75,13 +86,21 @@ export const CreateView = ({
75
86
  navigate({
76
87
  to: getAdminRoutePath('collections', '$collection', '$id'),
77
88
  params: { collection: path, id: result.documentId } as never,
78
- search: { action: 'created', from },
89
+ search: { ...(hookFailed ? {} : { action: 'created' as const }), from },
90
+ // Persistence has succeeded, but FormRenderer cannot commit its
91
+ // clean baseline until this handler returns. Do not let that brief
92
+ // dirty-state overlap block the intentional create → edit redirect.
93
+ ignoreBlocker: true,
79
94
  })
80
95
  } else {
81
96
  navigate({
82
97
  to: getAdminRoutePath('collections', '$collection'),
83
98
  params: { collection: path },
84
- search: { ...decodeListReturnState(from), action: 'created' },
99
+ search: {
100
+ ...decodeListReturnState(from),
101
+ ...(hookFailed ? {} : { action: 'created' as const }),
102
+ },
103
+ ignoreBlocker: true,
85
104
  })
86
105
  }
87
106
  } catch (err) {
@@ -29,6 +29,7 @@ import {
29
29
  deleteDocument,
30
30
  deleteDocumentLocale,
31
31
  duplicateCollectionDocument,
32
+ hasCommittedDocumentHookFailure,
32
33
  hasDeleteSideEffectFailures,
33
34
  scheduleCollectionDocumentPublish,
34
35
  unpublishDocument,
@@ -43,7 +44,7 @@ import type { SerializedDocumentPublishSchedule } from '../../server-fns/collect
43
44
  import type { ContentLocaleOption } from './view-menu.js'
44
45
 
45
46
  type EditState = {
46
- status: 'success' | 'failed' | 'busy' | 'idle'
47
+ status: 'success' | 'warning' | 'failed' | 'busy' | 'idle'
47
48
  message: string
48
49
  }
49
50
 
@@ -110,6 +111,16 @@ export const EditView = ({
110
111
  })
111
112
  }
112
113
 
114
+ const notifyCommittedHookFailure = () => {
115
+ const description = t('collections.save.hookFailedDescription')
116
+ toastManager.add({
117
+ title: t('collections.save.hookFailedToast'),
118
+ description,
119
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
120
+ })
121
+ setEditState({ status: 'warning', message: description })
122
+ }
123
+
113
124
  const handleLocaleChange = (newLocale: string) => {
114
125
  navigate({
115
126
  to: getAdminRoutePath('collections', '$collection', '$id'),
@@ -306,6 +317,15 @@ export const EditView = ({
306
317
  const result = await duplicateCollectionDocument({
307
318
  data: { collection: path, id: String(initialData.id) },
308
319
  })
320
+ if (hasCommittedDocumentHookFailure(result)) {
321
+ notifyCommittedHookFailure()
322
+ navigate({
323
+ to: getAdminRoutePath('collections', '$collection', '$id'),
324
+ params: { collection: path, id: result.documentId },
325
+ search: (prev: Record<string, unknown>) => ({ ...prev }),
326
+ })
327
+ return
328
+ }
309
329
  const description = result.pathRetried
310
330
  ? t('collections.edit.duplicatedAutoPathDescription', { path: result.newPath })
311
331
  : t('collections.edit.duplicatedPathDescription', { path: result.newPath })
@@ -367,6 +387,16 @@ export const EditView = ({
367
387
  overwrite,
368
388
  },
369
389
  })
390
+ if (hasCommittedDocumentHookFailure(result)) {
391
+ notifyCommittedHookFailure()
392
+ notifyScheduleSuspended()
393
+ navigate({
394
+ to: getAdminRoutePath('collections', '$collection', '$id'),
395
+ params: { collection: path, id: String(initialData.id) },
396
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: targetLocale }),
397
+ })
398
+ return
399
+ }
370
400
  const sourceLabel =
371
401
  contentLocales.find((l) => l.code === result.sourceLocale)?.label ?? result.sourceLocale
372
402
  const targetLabel =
@@ -435,6 +465,16 @@ export const EditView = ({
435
465
  locale: targetLocale,
436
466
  },
437
467
  })
468
+ if (hasCommittedDocumentHookFailure(result)) {
469
+ notifyCommittedHookFailure()
470
+ notifyScheduleSuspended()
471
+ navigate({
472
+ to: getAdminRoutePath('collections', '$collection', '$id'),
473
+ params: { collection: path, id: String(initialData.id) },
474
+ search: (prev: Record<string, unknown>) => ({ ...prev, locale: defaultContentLocale }),
475
+ })
476
+ return
477
+ }
438
478
  const targetLabel =
439
479
  contentLocales.find((l) => l.code === result.locale)?.label ?? result.locale
440
480
  const description = t('collections.edit.deletedLocaleDescription', {
@@ -498,7 +538,10 @@ export const EditView = ({
498
538
  close: true,
499
539
  },
500
540
  })
501
- setEditState({ status: 'success', message: description })
541
+ setEditState({
542
+ status: hasSideEffectFailures ? 'warning' : 'success',
543
+ message: description,
544
+ })
502
545
  // Navigate back to the collection list after deletion. The return
503
546
  // target has now been consumed — clear the stored fallback so a later
504
547
  // visit doesn't resurrect a stale list position.
@@ -550,6 +593,7 @@ export const EditView = ({
550
593
  systemAvailableLocales?: string[]
551
594
  }) => {
552
595
  try {
596
+ let hookFailed = false
553
597
  // Document-grain system fields write first via their own non-versioned
554
598
  // path — so a path conflict surfaces before we mint a content version,
555
599
  // and these immediate writes never reset workflow status. See
@@ -571,7 +615,7 @@ export const EditView = ({
571
615
  // fields changed, so a path/advertising edit never creates an empty
572
616
  // content version.
573
617
  if (contentDirty) {
574
- await updateCollectionDocumentWithPatches({
618
+ const result = await updateCollectionDocumentWithPatches({
575
619
  data: {
576
620
  collection: path,
577
621
  id: String(initialData.id),
@@ -580,33 +624,42 @@ export const EditView = ({
580
624
  locale,
581
625
  },
582
626
  })
627
+ hookFailed = hasCommittedDocumentHookFailure(result)
583
628
  }
584
629
 
585
630
  if (contentDirty && scheduledPublication?.state === 'armed') {
586
631
  notifyScheduleSuspended()
587
632
  }
588
633
 
589
- const description = t('collections.edit.updatedDescription', { label: singularLower })
634
+ const description = hookFailed
635
+ ? t('collections.save.hookFailedDescription')
636
+ : t('collections.edit.updatedDescription', { label: singularLower })
590
637
  toastManager.add({
591
- title: t('collections.edit.updateTitle', { label: singular }),
638
+ title: hookFailed
639
+ ? t('collections.save.hookFailedToast')
640
+ : t('collections.edit.updateTitle', { label: singular }),
592
641
  description,
593
642
  data: {
594
- intent: 'success',
595
- iconType: 'success',
643
+ intent: hookFailed ? 'warning' : 'success',
644
+ iconType: hookFailed ? 'warning' : 'success',
596
645
  icon: true,
597
646
  close: true,
598
647
  },
599
648
  })
600
649
 
601
- setEditState({ status: 'success', message: description })
650
+ setEditState({ status: hookFailed ? 'warning' : 'success', message: description })
602
651
 
603
652
  // Re-navigate to the same route so the loader re-fetches the document.
604
- // The new version will have a fresh version ID, draft status, and
605
- // updated publishedVersion metadata.
653
+ // The new version will have a fresh version ID, the collection's default
654
+ // workflow status, and updated publishedVersion metadata.
606
655
  navigate({
607
656
  to: getAdminRoutePath('collections', '$collection', '$id'),
608
657
  params: { collection: path, id: String(initialData.id) },
609
658
  search: (prev: Record<string, unknown>) => ({ ...prev }),
659
+ // Every write above has succeeded. FormRenderer clears its dirty
660
+ // baseline after this handler returns, so bypass the guard during the
661
+ // intentional post-save refresh rather than racing that state update.
662
+ ignoreBlocker: true,
610
663
  })
611
664
  } catch (err) {
612
665
  console.error('Network error:', err)
@@ -19,9 +19,10 @@
19
19
  import { useState } from 'react'
20
20
 
21
21
  import { useTranslation } from '@byline/i18n/react'
22
- import { Alert, Button, LoaderEllipsis, Modal } from '@byline/ui/react'
22
+ import { Alert, Button, LoaderEllipsis, Modal, useToastManager } from '@byline/ui/react'
23
23
  import cx from 'clsx'
24
24
 
25
+ import { hasCommittedDocumentHookFailure } from '../../server-fns/collections/save-outcome.js'
25
26
  import styles from './restore-version-modal.module.css'
26
27
 
27
28
  interface RestoreVersionModalProps {
@@ -42,6 +43,7 @@ export function RestoreVersionModal({
42
43
  onRestoreComplete,
43
44
  }: RestoreVersionModalProps) {
44
45
  const { t } = useTranslation('byline-admin')
46
+ const toastManager = useToastManager()
45
47
  const [error, setError] = useState<string | null>(null)
46
48
  const [pending, setPending] = useState(false)
47
49
 
@@ -50,7 +52,14 @@ export function RestoreVersionModal({
50
52
  setPending(true)
51
53
  setError(null)
52
54
  try {
53
- await restoreVersion()
55
+ const result = await restoreVersion()
56
+ if (hasCommittedDocumentHookFailure(result)) {
57
+ toastManager.add({
58
+ title: t('collections.save.hookFailedToast'),
59
+ description: t('collections.save.hookFailedDescription'),
60
+ data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
61
+ })
62
+ }
54
63
  onClose()
55
64
  await onRestoreComplete()
56
65
  } catch (err) {