@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.
- package/dist/admin-shell/chrome/router-pager.d.ts +7 -0
- package/dist/admin-shell/chrome/router-pager.js +13 -0
- package/dist/admin-shell/chrome/scroll-to-top.d.ts +20 -0
- package/dist/admin-shell/chrome/scroll-to-top.js +9 -0
- package/dist/admin-shell/collections/create.js +22 -5
- package/dist/admin-shell/collections/edit.js +85 -17
- package/dist/admin-shell/collections/restore-version-modal.js +14 -2
- package/dist/admin-shell/collections/save-navigation.test.d.ts +8 -0
- package/dist/admin-shell/singletons/view.js +10 -4
- package/dist/server-fns/collections/copy-to-locale.d.ts +2 -1
- package/dist/server-fns/collections/copy-to-locale.js +13 -6
- package/dist/server-fns/collections/create.d.ts +1 -1
- package/dist/server-fns/collections/create.js +15 -7
- package/dist/server-fns/collections/delete-locale.d.ts +2 -1
- package/dist/server-fns/collections/delete-locale.js +11 -4
- package/dist/server-fns/collections/duplicate.d.ts +2 -1
- package/dist/server-fns/collections/duplicate.js +10 -3
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/restore-version.d.ts +1 -1
- package/dist/server-fns/collections/restore-version.js +17 -10
- package/dist/server-fns/collections/save-outcome.d.ts +23 -0
- package/dist/server-fns/collections/save-outcome.js +18 -0
- package/dist/server-fns/collections/update.d.ts +3 -1
- package/dist/server-fns/collections/update.js +17 -7
- package/dist/server-fns/singletons/copy-to-locale.js +14 -5
- package/dist/server-fns/singletons/restore-version.js +10 -1
- package/dist/server-fns/singletons/update.js +12 -5
- package/package.json +10 -10
- package/src/admin-shell/chrome/router-pager.tsx +22 -0
- package/src/admin-shell/chrome/scroll-to-top.test.ts +56 -0
- package/src/admin-shell/chrome/scroll-to-top.ts +28 -0
- package/src/admin-shell/collections/create.tsx +22 -3
- package/src/admin-shell/collections/edit.tsx +63 -10
- package/src/admin-shell/collections/restore-version-modal.tsx +11 -2
- package/src/admin-shell/collections/save-navigation.test.tsx +384 -0
- package/src/admin-shell/singletons/history.test.tsx +43 -0
- package/src/admin-shell/singletons/view.test.tsx +27 -0
- package/src/admin-shell/singletons/view.tsx +35 -16
- package/src/server-fns/collections/copy-to-locale.ts +43 -29
- package/src/server-fns/collections/create.ts +16 -8
- package/src/server-fns/collections/delete-locale.ts +38 -24
- package/src/server-fns/collections/duplicate.ts +38 -24
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/restore-version.ts +16 -9
- package/src/server-fns/collections/save-outcome.test.node.ts +77 -0
- package/src/server-fns/collections/save-outcome.ts +55 -0
- package/src/server-fns/collections/update.ts +23 -8
- package/src/server-fns/singletons/copy-to-locale.ts +16 -9
- package/src/server-fns/singletons/restore-version.ts +12 -3
- package/src/server-fns/singletons/singletons.test.node.ts +34 -0
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 })=>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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 })=>
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
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/
|
|
129
|
-
"@byline/
|
|
130
|
-
"@byline/
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/
|
|
135
|
-
"@byline/
|
|
136
|
-
"@byline/
|
|
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 {
|
|
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: {
|
|
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({
|
|
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 =
|
|
634
|
+
const description = hookFailed
|
|
635
|
+
? t('collections.save.hookFailedDescription')
|
|
636
|
+
: t('collections.edit.updatedDescription', { label: singularLower })
|
|
590
637
|
toastManager.add({
|
|
591
|
-
title:
|
|
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,
|
|
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) {
|