@byline/host-tanstack-start 6.2.1 → 6.4.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/breadcrumbs/breadcrumbs-provider.js +13 -2
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +2 -6
- package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.test.d.ts +8 -0
- package/dist/admin-shell/collections/api.js +3 -2
- package/dist/admin-shell/collections/edit.js +3 -2
- package/dist/admin-shell/collections/restore-version-modal.js +19 -0
- package/dist/admin-shell/describe-mutation-failure.d.ts +18 -0
- package/dist/admin-shell/describe-mutation-failure.js +9 -0
- package/dist/admin-shell/singletons/view.js +2 -1
- package/dist/integrations/byline-field-services.d.ts +19 -0
- package/dist/integrations/byline-field-services.js +18 -2
- package/dist/routes/create-admin-layout-route.js +6 -3
- package/dist/server-fns/collections/create.js +4 -2
- package/dist/server-fns/collections/restore-version.d.ts +1 -0
- package/dist/server-fns/collections/restore-version.js +4 -1
- package/dist/server-fns/collections/update.js +3 -2
- package/dist/server-fns/document-mutation-errors.js +7 -1
- package/dist/server-fns/document-write-input.d.ts +9 -0
- package/dist/server-fns/document-write-input.js +44 -0
- package/dist/server-fns/singletons/restore-version.d.ts +1 -1
- package/package.json +16 -16
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs-provider.tsx +34 -4
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.test.tsx +274 -0
- package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +7 -7
- package/src/admin-shell/collections/api.tsx +17 -2
- package/src/admin-shell/collections/edit.tsx +3 -2
- package/src/admin-shell/collections/restore-version-modal.tsx +26 -0
- package/src/admin-shell/describe-mutation-failure.ts +30 -0
- package/src/admin-shell/singletons/view.tsx +2 -1
- package/src/integrations/byline-field-services.test.node.ts +129 -0
- package/src/integrations/byline-field-services.ts +37 -0
- package/src/routes/create-admin-layout-route.tsx +17 -6
- package/src/server-fns/collections/create.ts +5 -2
- package/src/server-fns/collections/restore-version.ts +4 -0
- package/src/server-fns/collections/update.ts +3 -2
- package/src/server-fns/document-mutation-errors.ts +8 -0
- package/src/server-fns/document-mutation-transport.test.node.ts +41 -0
- package/src/server-fns/document-write-input.test.node.ts +48 -0
- package/src/server-fns/document-write-input.ts +67 -0
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
|
-
import { createContext, useContext, useState } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useState } from "react";
|
|
3
3
|
const BreadcrumbsContext = /*#__PURE__*/ createContext(void 0);
|
|
4
|
+
function sameBreadcrumbs(a, b) {
|
|
5
|
+
if (a === b) return true;
|
|
6
|
+
if (a.length !== b.length) return false;
|
|
7
|
+
return a.every((crumb, index)=>crumb.label === b[index].label && crumb.href === b[index].href && crumb.active === b[index].active);
|
|
8
|
+
}
|
|
9
|
+
function sameSettings(a, b) {
|
|
10
|
+
return a.homeLabel === b.homeLabel && a.homePath === b.homePath && sameBreadcrumbs(a.breadcrumbs, b.breadcrumbs);
|
|
11
|
+
}
|
|
4
12
|
function BreadcrumbsProvider({ children }) {
|
|
5
13
|
const [breadcrumbSettings, setBreadcrumbSettings] = useState({
|
|
6
14
|
homeLabel: 'Home',
|
|
7
15
|
homePath: '/',
|
|
8
16
|
breadcrumbs: []
|
|
9
17
|
});
|
|
18
|
+
const setBreadcrumbs = useCallback((settings)=>{
|
|
19
|
+
setBreadcrumbSettings((previous)=>sameSettings(previous, settings) ? previous : settings);
|
|
20
|
+
}, []);
|
|
10
21
|
return /*#__PURE__*/ jsx(BreadcrumbsContext, {
|
|
11
22
|
value: {
|
|
12
23
|
breadCrumbSettings: breadcrumbSettings,
|
|
13
|
-
setBreadcrumbs
|
|
24
|
+
setBreadcrumbs
|
|
14
25
|
},
|
|
15
26
|
children: children
|
|
16
27
|
});
|
|
@@ -18,11 +18,6 @@ function Breadcrumbs({ breadcrumbs, className, homeLabel = 'Home', homePath = '/
|
|
|
18
18
|
const navRef = useRef(null);
|
|
19
19
|
const measureRef = useRef(null);
|
|
20
20
|
const [visibleIndices, setVisibleIndices] = useState(()=>breadcrumbs.map((_, i)=>i));
|
|
21
|
-
useIsoLayoutEffect(()=>{
|
|
22
|
-
setVisibleIndices(breadcrumbs.map((_, i)=>i));
|
|
23
|
-
}, [
|
|
24
|
-
breadcrumbs
|
|
25
|
-
]);
|
|
26
21
|
useIsoLayoutEffect(()=>{
|
|
27
22
|
const nav = navRef.current;
|
|
28
23
|
const measure = measureRef.current;
|
|
@@ -71,7 +66,8 @@ function Breadcrumbs({ breadcrumbs, className, homeLabel = 'Home', homePath = '/
|
|
|
71
66
|
ro.observe(nav);
|
|
72
67
|
return ()=>ro.disconnect();
|
|
73
68
|
}, [
|
|
74
|
-
breadcrumbs
|
|
69
|
+
breadcrumbs,
|
|
70
|
+
homeLabel
|
|
75
71
|
]);
|
|
76
72
|
const overflowed = useMemo(()=>{
|
|
77
73
|
const visible = new Set(visibleIndices);
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export {};
|
|
@@ -2,10 +2,11 @@ import { jsx, jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useTranslation } from "@byline/i18n/react";
|
|
3
3
|
import { Container, Section } from "@byline/ui/react";
|
|
4
4
|
import clsx from "clsx";
|
|
5
|
-
import { JsonView,
|
|
5
|
+
import { JsonView, darkStyles } from "react-json-view-lite";
|
|
6
6
|
import api_module from "./api.module.js";
|
|
7
7
|
import { ViewMenu } from "./view-menu.js";
|
|
8
8
|
import "react-json-view-lite/dist/index.css";
|
|
9
|
+
const expandToFirstLevel = (level)=>level < 2;
|
|
9
10
|
const ApiView = ({ collectionDefinition, initialData, locale, depth, contentLocales, defaultContentLocale })=>{
|
|
10
11
|
const { labels, path } = collectionDefinition;
|
|
11
12
|
const { t } = useTranslation('byline-admin');
|
|
@@ -38,7 +39,7 @@ const ApiView = ({ collectionDefinition, initialData, locale, depth, contentLoca
|
|
|
38
39
|
className: clsx('byline-api-viewer', api_module.viewer),
|
|
39
40
|
children: /*#__PURE__*/ jsx(JsonView, {
|
|
40
41
|
data: initialData,
|
|
41
|
-
shouldExpandNode:
|
|
42
|
+
shouldExpandNode: expandToFirstLevel,
|
|
42
43
|
style: {
|
|
43
44
|
...darkStyles,
|
|
44
45
|
container: 'api-json-view'
|
|
@@ -8,6 +8,7 @@ import { getAdminRoutePath } from "../../routes/admin-path.js";
|
|
|
8
8
|
import { clearListReturnState } from "../../routes/list-return-storage.js";
|
|
9
9
|
import { cancelCollectionDocumentScheduledPublish, confirmCollectionDocumentScheduledPublish, copyDocumentToLocale, deleteDocument, deleteDocumentLocale, duplicateCollectionDocument, hasCommittedDocumentHookFailure, hasDeleteSideEffectFailures, scheduleCollectionDocumentPublish, unpublishDocument, updateCollectionDocumentWithPatches, updateDocumentStatus } from "../../server-fns/collections/index.js";
|
|
10
10
|
import { useNavigate } from "../chrome/loose-router.js";
|
|
11
|
+
import { describeMutationFailure } from "../describe-mutation-failure.js";
|
|
11
12
|
import { useDocumentMutationState } from "../document-mutation-state.js";
|
|
12
13
|
import { useTanStackNavigationGuard } from "./tanstack-navigation-guard.js";
|
|
13
14
|
import { ViewMenu } from "./view-menu.js";
|
|
@@ -432,7 +433,7 @@ const EditView = ({ collectionDefinition, adminConfig, initialData: loadedData,
|
|
|
432
433
|
}
|
|
433
434
|
console.error('Duplicate error:', err);
|
|
434
435
|
const description = t("collections.edit.duplicateFailedDescription", {
|
|
435
|
-
message: t
|
|
436
|
+
message: describeMutationFailure(err, t)
|
|
436
437
|
});
|
|
437
438
|
toastManager.add({
|
|
438
439
|
title: t('collections.edit.duplicateTitle', {
|
|
@@ -533,7 +534,7 @@ const EditView = ({ collectionDefinition, adminConfig, initialData: loadedData,
|
|
|
533
534
|
}
|
|
534
535
|
console.error('Copy to locale error:', err);
|
|
535
536
|
const description = t("collections.edit.copyFailedDescription", {
|
|
536
|
-
message: t
|
|
537
|
+
message: describeMutationFailure(err, t)
|
|
537
538
|
});
|
|
538
539
|
toastManager.add({
|
|
539
540
|
title: t('collections.edit.copyToLocaleTitle', {
|
|
@@ -17,6 +17,19 @@ function RestoreVersionModal({ disabled = false, onMutationError, versionLabel,
|
|
|
17
17
|
setError(null);
|
|
18
18
|
try {
|
|
19
19
|
const result = await restoreVersion();
|
|
20
|
+
const issues = getRestoreValidationIssues(result);
|
|
21
|
+
if (issues.length > 0) toastManager.add({
|
|
22
|
+
title: t('collections.restore.validationIssuesToast'),
|
|
23
|
+
description: t("collections.restore.validationIssuesDescription", {
|
|
24
|
+
fields: issues.join(', ')
|
|
25
|
+
}),
|
|
26
|
+
data: {
|
|
27
|
+
intent: 'warning',
|
|
28
|
+
iconType: 'warning',
|
|
29
|
+
icon: true,
|
|
30
|
+
close: true
|
|
31
|
+
}
|
|
32
|
+
});
|
|
20
33
|
if (hasCommittedDocumentHookFailure(result)) toastManager.add({
|
|
21
34
|
title: t('collections.save.hookFailedToast'),
|
|
22
35
|
description: t("collections.save.hookFailedDescription"),
|
|
@@ -126,6 +139,12 @@ function RestoreVersionModal({ disabled = false, onMutationError, versionLabel,
|
|
|
126
139
|
]
|
|
127
140
|
});
|
|
128
141
|
}
|
|
142
|
+
function getRestoreValidationIssues(result) {
|
|
143
|
+
if (null == result || 'object' != typeof result) return [];
|
|
144
|
+
const issues = result.validationIssues;
|
|
145
|
+
if (!Array.isArray(issues)) return [];
|
|
146
|
+
return issues.map((issue)=>null != issue && 'object' == typeof issue && 'string' == typeof issue.field ? issue.field : '').filter((field)=>field.length > 0);
|
|
147
|
+
}
|
|
129
148
|
function getErrorCode(err) {
|
|
130
149
|
return 'string' == typeof err?.code ? err.code : null;
|
|
131
150
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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 { TranslationValues } from '@byline/i18n';
|
|
9
|
+
/**
|
|
10
|
+
* Describe a failed document mutation for the editor.
|
|
11
|
+
*
|
|
12
|
+
* Content writes that are not restores — create, update, patch saves,
|
|
13
|
+
* duplication and locale copies — keep the field-validation gate, because each
|
|
14
|
+
* reads or authors content the editor can open and correct. Naming the
|
|
15
|
+
* offending fields is what makes that correction actionable; without it the
|
|
16
|
+
* editor sees only a generic failure.
|
|
17
|
+
*/
|
|
18
|
+
export declare function describeMutationFailure(err: unknown, t: (key: string, values?: TranslationValues) => string): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { getDocumentFieldValidationDetails } from "@byline/core";
|
|
2
|
+
function describeMutationFailure(err, t) {
|
|
3
|
+
const validation = getDocumentFieldValidationDetails(err);
|
|
4
|
+
if (null == validation) return t('documentConcurrency.failed');
|
|
5
|
+
return t('collections.edit.invalidFieldsMessage', {
|
|
6
|
+
fields: validation.issues.map((issue)=>issue.field || '?').join(', ')
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export { describeMutationFailure };
|
|
@@ -10,6 +10,7 @@ import { hasCommittedDocumentHookFailure } from "../../server-fns/collections/in
|
|
|
10
10
|
import { cancelSingletonScheduledPublish, changeSingletonStatus, confirmSingletonScheduledPublish, copySingletonToLocale, scheduleSingletonPublish, unpublishSingleton, updateSingleton } from "../../server-fns/singletons/index.js";
|
|
11
11
|
import { useNavigate } from "../chrome/loose-router.js";
|
|
12
12
|
import { useTanStackNavigationGuard } from "../collections/tanstack-navigation-guard.js";
|
|
13
|
+
import { describeMutationFailure } from "../describe-mutation-failure.js";
|
|
13
14
|
import { useDocumentMutationState } from "../document-mutation-state.js";
|
|
14
15
|
import { SingletonViewMenu } from "./view-menu.js";
|
|
15
16
|
function SingletonView({ singletonDefinition, adminConfig, document: loadedDocument, initialData, locale, contentLocales, defaultContentLocale }) {
|
|
@@ -201,7 +202,7 @@ function SingletonView({ singletonDefinition, adminConfig, document: loadedDocum
|
|
|
201
202
|
toast(t('collections.edit.copyToLocaleTitle', {
|
|
202
203
|
label
|
|
203
204
|
}), t("collections.edit.copyFailedDescription", {
|
|
204
|
-
message: t
|
|
205
|
+
message: describeMutationFailure(err, t)
|
|
205
206
|
}), 'danger');
|
|
206
207
|
throw err;
|
|
207
208
|
}
|
|
@@ -16,3 +16,22 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { BylineFieldServices } from '@byline/admin/react';
|
|
18
18
|
export declare const bylineFieldServices: BylineFieldServices;
|
|
19
|
+
/** The subset of the admin session the field services need. */
|
|
20
|
+
export interface FieldServicesViewer {
|
|
21
|
+
is_super_admin: boolean;
|
|
22
|
+
abilities: ReadonlyArray<string>;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Content-addressed memo key for the services object.
|
|
26
|
+
*
|
|
27
|
+
* Avoids notifying field-service context consumers when the host supplies an
|
|
28
|
+
* equivalent viewer object or abilities array. Existing service functions retain
|
|
29
|
+
* their module-level identity even when the containing object is rebuilt.
|
|
30
|
+
* Ability order is not meaningful, so sort a copy before building the key.
|
|
31
|
+
*/
|
|
32
|
+
export declare function abilityFingerprint(viewer: FieldServicesViewer): string;
|
|
33
|
+
/**
|
|
34
|
+
* Field services for one viewer. Call inside `useMemo`, keyed on
|
|
35
|
+
* `abilityFingerprint(viewer)`.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildBylineFieldServices(viewer: FieldServicesViewer): BylineFieldServices;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getAdminRoutePath } from "../routes/admin-path.js";
|
|
1
2
|
import { getCollectionDocuments } from "../server-fns/collections/list.js";
|
|
2
3
|
import { getTreeAncestors, getTreeParent, placeTreeNode, removeFromTree } from "../server-fns/collections/tree.js";
|
|
3
4
|
import { uploadField } from "../server-fns/collections/upload.js";
|
|
@@ -20,12 +21,27 @@ const byline_field_services_getTreeAncestors = (input)=>getTreeAncestors({
|
|
|
20
21
|
const byline_field_services_getTreeParent = (input)=>getTreeParent({
|
|
21
22
|
data: input
|
|
22
23
|
});
|
|
24
|
+
const getCreateDocumentUrl = (collectionPath)=>getAdminRoutePath('collections', collectionPath, 'create');
|
|
23
25
|
const bylineFieldServices = {
|
|
24
26
|
getCollectionDocuments: byline_field_services_getCollectionDocuments,
|
|
25
27
|
uploadField: byline_field_services_uploadField,
|
|
26
28
|
placeTreeNode: byline_field_services_placeTreeNode,
|
|
27
29
|
removeFromTree: byline_field_services_removeFromTree,
|
|
28
30
|
getTreeAncestors: byline_field_services_getTreeAncestors,
|
|
29
|
-
getTreeParent: byline_field_services_getTreeParent
|
|
31
|
+
getTreeParent: byline_field_services_getTreeParent,
|
|
32
|
+
getCreateDocumentUrl: getCreateDocumentUrl
|
|
30
33
|
};
|
|
31
|
-
|
|
34
|
+
function abilityFingerprint(viewer) {
|
|
35
|
+
if (viewer.is_super_admin) return 'super-admin';
|
|
36
|
+
return [
|
|
37
|
+
...viewer.abilities
|
|
38
|
+
].sort().join(' ');
|
|
39
|
+
}
|
|
40
|
+
function buildBylineFieldServices(viewer) {
|
|
41
|
+
const canCreateInCollection = (collectionPath)=>viewer.is_super_admin || viewer.abilities.includes(`collections.${collectionPath}.create`);
|
|
42
|
+
return {
|
|
43
|
+
...bylineFieldServices,
|
|
44
|
+
canCreateInCollection
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export { abilityFingerprint, buildBylineFieldServices, bylineFieldServices };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useEffect } from "react";
|
|
2
|
+
import { useEffect, useMemo } from "react";
|
|
3
3
|
import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|
4
4
|
import { applyStoredTheme } from "@byline/admin/admin-account/components/theme";
|
|
5
5
|
import { BylineFieldServicesProvider } from "@byline/admin/react";
|
|
@@ -18,7 +18,7 @@ import { SessionChangeBoundary } from "../admin-shell/chrome/session-change-boun
|
|
|
18
18
|
import { buildLocaleDefinitions } from "../i18n/locale-definitions.js";
|
|
19
19
|
import { bylineAdminServices } from "../integrations/byline-admin-services.js";
|
|
20
20
|
import { BylineAiAdminProvider } from "../integrations/byline-ai.js";
|
|
21
|
-
import {
|
|
21
|
+
import { abilityFingerprint, buildBylineFieldServices } from "../integrations/byline-field-services.js";
|
|
22
22
|
import { observeSession, sessionIsChanged } from "../integrations/session-coordination.js";
|
|
23
23
|
import { renewExpectedSession } from "../integrations/session-renewal.js";
|
|
24
24
|
import { getAnalyticsRuntime } from "../server-fns/analytics/index.js";
|
|
@@ -58,6 +58,9 @@ function createAdminLayoutRoute(path) {
|
|
|
58
58
|
useEffect(()=>{
|
|
59
59
|
applyStoredTheme();
|
|
60
60
|
}, []);
|
|
61
|
+
const fieldServices = useMemo(()=>buildBylineFieldServices(user), [
|
|
62
|
+
abilityFingerprint(user)
|
|
63
|
+
]);
|
|
61
64
|
const handleSetLocale = async (next)=>{
|
|
62
65
|
await setAdminLocaleFn({
|
|
63
66
|
data: {
|
|
@@ -77,7 +80,7 @@ function createAdminLayoutRoute(path) {
|
|
|
77
80
|
children: /*#__PURE__*/ jsx(BylineAdminServicesProvider, {
|
|
78
81
|
services: bylineAdminServices,
|
|
79
82
|
children: /*#__PURE__*/ jsx(BylineFieldServicesProvider, {
|
|
80
|
-
services:
|
|
83
|
+
services: fieldServices,
|
|
81
84
|
children: /*#__PURE__*/ jsx(BylineAiAdminProvider, {
|
|
82
85
|
children: /*#__PURE__*/ jsxs(AdminMenuProvider, {
|
|
83
86
|
children: [
|
|
@@ -2,15 +2,17 @@ import { createServerFn } from "@tanstack/react-start";
|
|
|
2
2
|
import { getAdminRequestContext } from "@byline/client/server";
|
|
3
3
|
import { ERR_NOT_FOUND, getLogger, getServerConfig } from "@byline/core";
|
|
4
4
|
import { createDocument } from "@byline/core/services";
|
|
5
|
+
import { getDefaultStatus } from "@byline/core/workflow";
|
|
5
6
|
import { ensureCollection } from "../../integrations/api-utils.js";
|
|
6
7
|
import { adminSessionMiddleware } from "../../integrations/session-middleware.js";
|
|
7
8
|
import { withDocumentMutationErrors } from "../document-mutation-errors.js";
|
|
9
|
+
import { validateDocumentWriteInput } from "../document-write-input.js";
|
|
8
10
|
import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
|
|
9
11
|
const createCollectionDocument = createServerFn({
|
|
10
12
|
method: 'POST'
|
|
11
13
|
}).middleware([
|
|
12
14
|
adminSessionMiddleware
|
|
13
|
-
]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
15
|
+
]).validator((input)=>validateDocumentWriteInput(input, 'create')).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
14
16
|
const { collection: path, data: documentData, locale, path: explicitPath, availableLocales } = input;
|
|
15
17
|
const logger = getLogger();
|
|
16
18
|
const config = await ensureCollection(path);
|
|
@@ -36,7 +38,7 @@ const createCollectionDocument = createServerFn({
|
|
|
36
38
|
try {
|
|
37
39
|
result = await createDocument(ctx, {
|
|
38
40
|
data: structuredClone(documentData),
|
|
39
|
-
status:
|
|
41
|
+
status: getDefaultStatus(config.definition),
|
|
40
42
|
locale: locale ?? serverConfig.i18n.content.defaultLocale,
|
|
41
43
|
path: explicitPath,
|
|
42
44
|
availableLocales
|
|
@@ -43,7 +43,10 @@ const restore_version_restoreDocumentVersion = createServerFn({
|
|
|
43
43
|
documentId: result.documentId,
|
|
44
44
|
revision: result.revision,
|
|
45
45
|
documentVersionId: result.documentVersionId,
|
|
46
|
-
sourceVersionId: result.sourceVersionId
|
|
46
|
+
sourceVersionId: result.sourceVersionId,
|
|
47
|
+
...result.validationIssues ? {
|
|
48
|
+
validationIssues: result.validationIssues
|
|
49
|
+
} : {}
|
|
47
50
|
};
|
|
48
51
|
} catch (error) {
|
|
49
52
|
const committedFailure = toCommittedDocumentHookFailureResponse(error);
|
|
@@ -5,12 +5,13 @@ import { saveDocument, updateDocumentSystemFields } from "@byline/core/services"
|
|
|
5
5
|
import { ensureCollection } from "../../integrations/api-utils.js";
|
|
6
6
|
import { adminSessionMiddleware } from "../../integrations/session-middleware.js";
|
|
7
7
|
import { withDocumentMutationErrors } from "../document-mutation-errors.js";
|
|
8
|
+
import { validateDocumentWriteInput } from "../document-write-input.js";
|
|
8
9
|
import { toCommittedDocumentHookFailureResponse } from "./save-outcome.js";
|
|
9
10
|
const updateCollectionDocumentWithPatches = createServerFn({
|
|
10
11
|
method: 'POST'
|
|
11
12
|
}).middleware([
|
|
12
13
|
adminSessionMiddleware
|
|
13
|
-
]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
14
|
+
]).validator((input)=>validateDocumentWriteInput(input, 'patch')).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
14
15
|
const { collection: path, id, patches, expectedRevision, locale, path: explicitPath, availableLocales } = input;
|
|
15
16
|
const logger = getLogger();
|
|
16
17
|
const config = await ensureCollection(path);
|
|
@@ -60,7 +61,7 @@ const updateCollectionDocumentSystemFields = createServerFn({
|
|
|
60
61
|
method: 'POST'
|
|
61
62
|
}).middleware([
|
|
62
63
|
adminSessionMiddleware
|
|
63
|
-
]).validator((input)=>input).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
64
|
+
]).validator((input)=>validateDocumentWriteInput(input, 'metadata')).handler(withDocumentMutationErrors(async ({ data: input })=>{
|
|
64
65
|
const { collection: path, id, expectedRevision, locale, path: explicitPath, availableLocales } = input;
|
|
65
66
|
const logger = getLogger();
|
|
66
67
|
const config = await ensureCollection(path);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { BylineError, ErrorCodes, getDocumentRevisionValidationDetails, getDocumentStaleDetails, getLockConflictDetails } from "@byline/core";
|
|
1
|
+
import { BylineError, ErrorCodes, getDocumentFieldValidationDetails, getDocumentRevisionValidationDetails, getDocumentStaleDetails, getLockConflictDetails } from "@byline/core";
|
|
2
2
|
import { getDocumentHookCommittedDetails } from "@byline/core/services";
|
|
3
3
|
import { committedTreeReceipt } from "./structural-receipt.js";
|
|
4
4
|
function documentMutationError(error) {
|
|
@@ -14,6 +14,12 @@ function documentMutationError(error) {
|
|
|
14
14
|
message: 'Reload this document before making changes.',
|
|
15
15
|
details: validation
|
|
16
16
|
};
|
|
17
|
+
const fields = getDocumentFieldValidationDetails(error);
|
|
18
|
+
if (fields) return {
|
|
19
|
+
code: ErrorCodes.VALIDATION,
|
|
20
|
+
message: 'Some document fields are invalid.',
|
|
21
|
+
details: fields
|
|
22
|
+
};
|
|
17
23
|
const lock = getLockConflictDetails(error);
|
|
18
24
|
if (lock) return {
|
|
19
25
|
code: ErrorCodes.LOCK_CONFLICT,
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
/** Shape checks complement lifecycle authorization, revision and field validation. */
|
|
9
|
+
export declare function validateDocumentWriteInput<T>(input: T, mode: 'create' | 'patch' | 'metadata'): T;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ERR_VALIDATION, parseInstancePath } from "@byline/core";
|
|
2
|
+
const object = (value)=>null !== value && 'object' == typeof value && !Array.isArray(value);
|
|
3
|
+
const document_write_input_text = (value)=>'string' == typeof value && value.trim().length > 0;
|
|
4
|
+
const index = (value)=>Number.isSafeInteger(value) && value >= 0;
|
|
5
|
+
const path = (value)=>document_write_input_text(value) && parseInstancePath(value).ok;
|
|
6
|
+
function patches(value) {
|
|
7
|
+
return Array.isArray(value) && value.every((patch)=>{
|
|
8
|
+
if (!object(patch) || !path(patch.path)) return false;
|
|
9
|
+
if (void 0 !== patch.opId && !document_write_input_text(patch.opId)) return false;
|
|
10
|
+
if (void 0 !== patch.timestamp && !document_write_input_text(patch.timestamp)) return false;
|
|
11
|
+
switch(patch.kind){
|
|
12
|
+
case 'field.set':
|
|
13
|
+
return 'value' in patch;
|
|
14
|
+
case 'field.clear':
|
|
15
|
+
return true;
|
|
16
|
+
case 'array.insert':
|
|
17
|
+
return 'item' in patch && (void 0 === patch.index || index(patch.index));
|
|
18
|
+
case 'array.move':
|
|
19
|
+
return document_write_input_text(patch.itemId) && index(patch.toIndex);
|
|
20
|
+
case 'array.remove':
|
|
21
|
+
return document_write_input_text(patch.itemId);
|
|
22
|
+
case 'array.updateItem':
|
|
23
|
+
return document_write_input_text(patch.itemId) && patches(patch.patches);
|
|
24
|
+
case 'block.add':
|
|
25
|
+
return document_write_input_text(patch.blockType) && (void 0 === patch.index || index(patch.index));
|
|
26
|
+
case 'block.move':
|
|
27
|
+
return document_write_input_text(patch.blockId) && index(patch.toIndex);
|
|
28
|
+
case 'block.remove':
|
|
29
|
+
return document_write_input_text(patch.blockId);
|
|
30
|
+
case 'block.updateField':
|
|
31
|
+
return document_write_input_text(patch.blockId) && path(patch.fieldPath) && 'value' in patch;
|
|
32
|
+
default:
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function validateDocumentWriteInput(input, mode) {
|
|
38
|
+
const valid = object(input) && document_write_input_text(input.collection) && ('create' === mode ? object(input.data) : document_write_input_text(input.id)) && (void 0 === input.locale || document_write_input_text(input.locale)) && (null == input.path || 'string' == typeof input.path) && (void 0 === input.availableLocales || Array.isArray(input.availableLocales) && input.availableLocales.every(document_write_input_text)) && ('patch' !== mode || patches(input.patches));
|
|
39
|
+
if (!valid) throw ERR_VALIDATION({
|
|
40
|
+
message: 'Invalid document write request.'
|
|
41
|
+
});
|
|
42
|
+
return input;
|
|
43
|
+
}
|
|
44
|
+
export { validateDocumentWriteInput };
|
|
@@ -15,4 +15,4 @@ export declare const restoreSingletonVersion: import("@tanstack/react-start").Re
|
|
|
15
15
|
expectedRevision: number;
|
|
16
16
|
singleton: string;
|
|
17
17
|
versionId: string;
|
|
18
|
-
}, Promise<import("@byline/core").
|
|
18
|
+
}, Promise<import("../collections/save-outcome.js").CollectionDocumentCommittedHookFailureResponse | import("@byline/core").RestoreSingletonVersionResult>>;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"private": false,
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
|
-
"version": "6.
|
|
6
|
+
"version": "6.4.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -124,16 +124,16 @@
|
|
|
124
124
|
"clsx": "^2.1.1",
|
|
125
125
|
"react-json-view-lite": "^2.5.0",
|
|
126
126
|
"react-swipeable": "^7.0.2",
|
|
127
|
-
"zod": "^4.6.
|
|
128
|
-
"@byline/
|
|
129
|
-
"@byline/analytics
|
|
130
|
-
"@byline/analytics": "6.
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/
|
|
135
|
-
"@byline/
|
|
136
|
-
"@byline/
|
|
127
|
+
"zod": "^4.6.5",
|
|
128
|
+
"@byline/ai": "6.4.0",
|
|
129
|
+
"@byline/analytics": "6.4.0",
|
|
130
|
+
"@byline/analytics-agent": "6.4.0",
|
|
131
|
+
"@byline/client": "6.4.0",
|
|
132
|
+
"@byline/ui": "6.4.0",
|
|
133
|
+
"@byline/admin": "6.4.0",
|
|
134
|
+
"@byline/auth": "6.4.0",
|
|
135
|
+
"@byline/core": "6.4.0",
|
|
136
|
+
"@byline/i18n": "6.4.0"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
139
139
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -145,10 +145,10 @@
|
|
|
145
145
|
"devDependencies": {
|
|
146
146
|
"@biomejs/biome": "2.5.13",
|
|
147
147
|
"@rsbuild/plugin-react": "^2.1.0",
|
|
148
|
-
"@rslib/core": "^1.0.
|
|
149
|
-
"@tanstack/react-router": "^1.170.
|
|
150
|
-
"@tanstack/react-start": "^1.168.
|
|
151
|
-
"@types/node": "^26.
|
|
148
|
+
"@rslib/core": "^1.0.1",
|
|
149
|
+
"@tanstack/react-router": "^1.170.36",
|
|
150
|
+
"@tanstack/react-start": "^1.168.54",
|
|
151
|
+
"@types/node": "^26.6.1",
|
|
152
152
|
"@types/react": "19.3.0",
|
|
153
153
|
"@types/react-dom": "19.3.0",
|
|
154
154
|
"@vitejs/plugin-react": "^6.1.1",
|
|
@@ -159,7 +159,7 @@
|
|
|
159
159
|
"typescript": "^7.0.2",
|
|
160
160
|
"typescript-plugin-css-modules": "^5.2.0",
|
|
161
161
|
"vite": "^8.3.0",
|
|
162
|
-
"vitest": "^5.0.
|
|
162
|
+
"vitest": "^5.0.1"
|
|
163
163
|
},
|
|
164
164
|
"publishConfig": {
|
|
165
165
|
"access": "public",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { createContext, type ReactNode, useContext, useState } from 'react'
|
|
9
|
+
import { createContext, type ReactNode, useCallback, useContext, useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import type { Breadcrumb } from './@types.js'
|
|
12
12
|
|
|
@@ -23,6 +23,32 @@ interface BreadcrumbsContextType {
|
|
|
23
23
|
|
|
24
24
|
const BreadcrumbsContext = createContext<BreadcrumbsContextType | undefined>(undefined)
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Route components build their breadcrumb trail from an inline array literal,
|
|
28
|
+
* so every render submits a new array that is almost always value-identical to
|
|
29
|
+
* the last one. Comparing by value lets the provider hand back the previous
|
|
30
|
+
* state, which keeps the array reference — and therefore the measurement
|
|
31
|
+
* effect in `Breadcrumbs` — stable across unrelated renders.
|
|
32
|
+
*/
|
|
33
|
+
function sameBreadcrumbs(a: Breadcrumb[], b: Breadcrumb[]): boolean {
|
|
34
|
+
if (a === b) return true
|
|
35
|
+
if (a.length !== b.length) return false
|
|
36
|
+
return a.every(
|
|
37
|
+
(crumb, index) =>
|
|
38
|
+
crumb.label === b[index].label &&
|
|
39
|
+
crumb.href === b[index].href &&
|
|
40
|
+
crumb.active === b[index].active
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sameSettings(a: BreadcrumbsSettings, b: BreadcrumbsSettings): boolean {
|
|
45
|
+
return (
|
|
46
|
+
a.homeLabel === b.homeLabel &&
|
|
47
|
+
a.homePath === b.homePath &&
|
|
48
|
+
sameBreadcrumbs(a.breadcrumbs, b.breadcrumbs)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
export function BreadcrumbsProvider({ children }: { children: ReactNode }) {
|
|
27
53
|
const [breadcrumbSettings, setBreadcrumbSettings] = useState<BreadcrumbsSettings>({
|
|
28
54
|
homeLabel: 'Home',
|
|
@@ -30,10 +56,14 @@ export function BreadcrumbsProvider({ children }: { children: ReactNode }) {
|
|
|
30
56
|
breadcrumbs: [],
|
|
31
57
|
})
|
|
32
58
|
|
|
59
|
+
// `BreadcrumbsClient` lists this setter in its effect dependencies, so it
|
|
60
|
+
// has to stay referentially stable or the effect refires on every render.
|
|
61
|
+
const setBreadcrumbs = useCallback((settings: BreadcrumbsSettings) => {
|
|
62
|
+
setBreadcrumbSettings((previous) => (sameSettings(previous, settings) ? previous : settings))
|
|
63
|
+
}, [])
|
|
64
|
+
|
|
33
65
|
return (
|
|
34
|
-
<BreadcrumbsContext
|
|
35
|
-
value={{ breadCrumbSettings: breadcrumbSettings, setBreadcrumbs: setBreadcrumbSettings }}
|
|
36
|
-
>
|
|
66
|
+
<BreadcrumbsContext value={{ breadCrumbSettings: breadcrumbSettings, setBreadcrumbs }}>
|
|
37
67
|
{children}
|
|
38
68
|
</BreadcrumbsContext>
|
|
39
69
|
)
|