@byline/host-tanstack-start 6.3.0 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-shell/collections/edit.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/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 +18 -18
- 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/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
|
@@ -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
|
}
|
|
@@ -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.5.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/admin": "6.
|
|
129
|
-
"@byline/
|
|
130
|
-
"@byline/analytics-agent": "6.
|
|
131
|
-
"@byline/
|
|
132
|
-
"@byline/
|
|
133
|
-
"@byline/
|
|
134
|
-
"@byline/i18n": "6.
|
|
135
|
-
"@byline/
|
|
136
|
-
"@byline/
|
|
127
|
+
"zod": "^4.6.5",
|
|
128
|
+
"@byline/admin": "6.5.0",
|
|
129
|
+
"@byline/ai": "6.5.0",
|
|
130
|
+
"@byline/analytics-agent": "6.5.0",
|
|
131
|
+
"@byline/client": "6.5.0",
|
|
132
|
+
"@byline/auth": "6.5.0",
|
|
133
|
+
"@byline/analytics": "6.5.0",
|
|
134
|
+
"@byline/i18n": "6.5.0",
|
|
135
|
+
"@byline/core": "6.5.0",
|
|
136
|
+
"@byline/ui": "6.5.0"
|
|
137
137
|
},
|
|
138
138
|
"peerDependencies": {
|
|
139
139
|
"@tanstack/react-router": "^1.167.0",
|
|
@@ -143,23 +143,23 @@
|
|
|
143
143
|
"vite": "^8.0.0"
|
|
144
144
|
},
|
|
145
145
|
"devDependencies": {
|
|
146
|
-
"@biomejs/biome": "2.5.
|
|
146
|
+
"@biomejs/biome": "2.5.14",
|
|
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.38",
|
|
150
|
+
"@tanstack/react-start": "^1.168.56",
|
|
151
|
+
"@types/node": "^26.6.2",
|
|
152
152
|
"@types/react": "19.3.0",
|
|
153
153
|
"@types/react-dom": "19.3.0",
|
|
154
154
|
"@vitejs/plugin-react": "^6.1.1",
|
|
155
|
-
"jsdom": "^30.0
|
|
155
|
+
"jsdom": "^30.1.0",
|
|
156
156
|
"npm-run-all": "^4.1.5",
|
|
157
157
|
"react": "^19.3.0",
|
|
158
158
|
"react-dom": "^19.3.0",
|
|
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",
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
updateDocumentStatus,
|
|
38
38
|
} from '../../server-fns/collections/index.js'
|
|
39
39
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
40
|
+
import { describeMutationFailure } from '../describe-mutation-failure.js'
|
|
40
41
|
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
41
42
|
import { useTanStackNavigationGuard } from './tanstack-navigation-guard.js'
|
|
42
43
|
import { ViewMenu } from './view-menu.js'
|
|
@@ -430,7 +431,7 @@ export const EditView = ({
|
|
|
430
431
|
}
|
|
431
432
|
console.error('Duplicate error:', err)
|
|
432
433
|
const description = t('collections.edit.duplicateFailedDescription', {
|
|
433
|
-
message: t
|
|
434
|
+
message: describeMutationFailure(err, t),
|
|
434
435
|
})
|
|
435
436
|
toastManager.add({
|
|
436
437
|
title: t('collections.edit.duplicateTitle', { label: singular }),
|
|
@@ -526,7 +527,7 @@ export const EditView = ({
|
|
|
526
527
|
}
|
|
527
528
|
console.error('Copy to locale error:', err)
|
|
528
529
|
const description = t('collections.edit.copyFailedDescription', {
|
|
529
|
-
message: t
|
|
530
|
+
message: describeMutationFailure(err, t),
|
|
530
531
|
})
|
|
531
532
|
toastManager.add({
|
|
532
533
|
title: t('collections.edit.copyToLocaleTitle', { label: singular }),
|
|
@@ -57,6 +57,18 @@ export function RestoreVersionModal({
|
|
|
57
57
|
setError(null)
|
|
58
58
|
try {
|
|
59
59
|
const result = await restoreVersion()
|
|
60
|
+
// Restore is exempt from field validation, so this never blocks it. Name
|
|
61
|
+
// the fields so the editor understands why the next save is refused.
|
|
62
|
+
const issues = getRestoreValidationIssues(result)
|
|
63
|
+
if (issues.length > 0) {
|
|
64
|
+
toastManager.add({
|
|
65
|
+
title: t('collections.restore.validationIssuesToast'),
|
|
66
|
+
description: t('collections.restore.validationIssuesDescription', {
|
|
67
|
+
fields: issues.join(', '),
|
|
68
|
+
}),
|
|
69
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
70
|
+
})
|
|
71
|
+
}
|
|
60
72
|
if (hasCommittedDocumentHookFailure(result)) {
|
|
61
73
|
toastManager.add({
|
|
62
74
|
title: t('collections.save.hookFailedToast'),
|
|
@@ -146,6 +158,20 @@ export function RestoreVersionModal({
|
|
|
146
158
|
)
|
|
147
159
|
}
|
|
148
160
|
|
|
161
|
+
/** Field paths of the restored content that today's validation rejects. */
|
|
162
|
+
function getRestoreValidationIssues(result: unknown): string[] {
|
|
163
|
+
if (result == null || typeof result !== 'object') return []
|
|
164
|
+
const issues = (result as { validationIssues?: unknown }).validationIssues
|
|
165
|
+
if (!Array.isArray(issues)) return []
|
|
166
|
+
return issues
|
|
167
|
+
.map((issue) =>
|
|
168
|
+
issue != null && typeof issue === 'object' && typeof (issue as any).field === 'string'
|
|
169
|
+
? (issue as { field: string }).field
|
|
170
|
+
: ''
|
|
171
|
+
)
|
|
172
|
+
.filter((field) => field.length > 0)
|
|
173
|
+
}
|
|
174
|
+
|
|
149
175
|
function getErrorCode(err: unknown): string | null {
|
|
150
176
|
return typeof (err as { code?: unknown })?.code === 'string'
|
|
151
177
|
? (err as { code: string }).code
|
|
@@ -0,0 +1,30 @@
|
|
|
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 { getDocumentFieldValidationDetails } from '@byline/core'
|
|
10
|
+
import type { TranslationValues } from '@byline/i18n'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Describe a failed document mutation for the editor.
|
|
14
|
+
*
|
|
15
|
+
* Content writes that are not restores — create, update, patch saves,
|
|
16
|
+
* duplication and locale copies — keep the field-validation gate, because each
|
|
17
|
+
* reads or authors content the editor can open and correct. Naming the
|
|
18
|
+
* offending fields is what makes that correction actionable; without it the
|
|
19
|
+
* editor sees only a generic failure.
|
|
20
|
+
*/
|
|
21
|
+
export function describeMutationFailure(
|
|
22
|
+
err: unknown,
|
|
23
|
+
t: (key: string, values?: TranslationValues) => string
|
|
24
|
+
): string {
|
|
25
|
+
const validation = getDocumentFieldValidationDetails(err)
|
|
26
|
+
if (validation == null) return t('documentConcurrency.failed')
|
|
27
|
+
return t('collections.edit.invalidFieldsMessage', {
|
|
28
|
+
fields: validation.issues.map((issue) => issue.field || '?').join(', '),
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from '../../server-fns/singletons/index.js'
|
|
38
38
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
39
39
|
import { useTanStackNavigationGuard } from '../collections/tanstack-navigation-guard.js'
|
|
40
|
+
import { describeMutationFailure } from '../describe-mutation-failure.js'
|
|
40
41
|
import { useDocumentMutationState } from '../document-mutation-state.js'
|
|
41
42
|
import { SingletonViewMenu } from './view-menu.js'
|
|
42
43
|
import type { ContentLocaleOption } from '../collections/view-menu.js'
|
|
@@ -287,7 +288,7 @@ export function SingletonView({
|
|
|
287
288
|
}
|
|
288
289
|
toast(
|
|
289
290
|
t('collections.edit.copyToLocaleTitle', { label }),
|
|
290
|
-
t('collections.edit.copyFailedDescription', { message: t
|
|
291
|
+
t('collections.edit.copyFailedDescription', { message: describeMutationFailure(err, t) }),
|
|
291
292
|
'danger'
|
|
292
293
|
)
|
|
293
294
|
throw err
|
|
@@ -12,10 +12,12 @@ import { getAdminRequestContext } from '@byline/client/server'
|
|
|
12
12
|
import { ERR_NOT_FOUND, getLogger, getServerConfig } from '@byline/core'
|
|
13
13
|
import type { CreateDocumentResult, DocumentLifecycleContext } from '@byline/core/services'
|
|
14
14
|
import { createDocument } from '@byline/core/services'
|
|
15
|
+
import { getDefaultStatus } from '@byline/core/workflow'
|
|
15
16
|
|
|
16
17
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
17
18
|
import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
|
|
18
19
|
import { withDocumentMutationErrors } from '../document-mutation-errors.js'
|
|
20
|
+
import { validateDocumentWriteInput } from '../document-write-input.js'
|
|
19
21
|
import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
|
|
20
22
|
|
|
21
23
|
// ---------------------------------------------------------------------------
|
|
@@ -31,7 +33,7 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
|
|
|
31
33
|
locale?: string
|
|
32
34
|
path?: string
|
|
33
35
|
availableLocales?: string[]
|
|
34
|
-
}) => input
|
|
36
|
+
}) => validateDocumentWriteInput(input, 'create')
|
|
35
37
|
)
|
|
36
38
|
.handler(
|
|
37
39
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -68,7 +70,8 @@ export const createCollectionDocument = createServerFn({ method: 'POST' })
|
|
|
68
70
|
try {
|
|
69
71
|
result = await createDocument(ctx, {
|
|
70
72
|
data: structuredClone(documentData),
|
|
71
|
-
status
|
|
73
|
+
// The admin create flow always uses the configured initial status.
|
|
74
|
+
status: getDefaultStatus(config.definition),
|
|
72
75
|
locale: locale ?? serverConfig.i18n.content.defaultLocale,
|
|
73
76
|
path: explicitPath,
|
|
74
77
|
availableLocales,
|
|
@@ -66,6 +66,10 @@ export const restoreDocumentVersion = createServerFn({ method: 'POST' })
|
|
|
66
66
|
revision: result.revision,
|
|
67
67
|
documentVersionId: result.documentVersionId,
|
|
68
68
|
sourceVersionId: result.sourceVersionId,
|
|
69
|
+
// Restore is exempt from field validation; these describe what
|
|
70
|
+
// today's rules object to, so the editor can say what must be fixed
|
|
71
|
+
// before the next ordinary save.
|
|
72
|
+
...(result.validationIssues ? { validationIssues: result.validationIssues } : {}),
|
|
69
73
|
}
|
|
70
74
|
} catch (error) {
|
|
71
75
|
const committedFailure = toCommittedDocumentHookFailureResponse(error)
|
|
@@ -17,6 +17,7 @@ import { saveDocument, updateDocumentSystemFields } from '@byline/core/services'
|
|
|
17
17
|
import { ensureCollection } from '../../integrations/api-utils.js'
|
|
18
18
|
import { adminSessionMiddleware } from '../../integrations/session-middleware.js'
|
|
19
19
|
import { withDocumentMutationErrors } from '../document-mutation-errors.js'
|
|
20
|
+
import { validateDocumentWriteInput } from '../document-write-input.js'
|
|
20
21
|
import { toCommittedDocumentHookFailureResponse } from './save-outcome.js'
|
|
21
22
|
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -35,7 +36,7 @@ export const updateCollectionDocumentWithPatches = createServerFn({ method: 'POS
|
|
|
35
36
|
path?: string | null
|
|
36
37
|
availableLocales?: string[]
|
|
37
38
|
locale?: string
|
|
38
|
-
}) => input
|
|
39
|
+
}) => validateDocumentWriteInput(input, 'patch')
|
|
39
40
|
)
|
|
40
41
|
.handler(
|
|
41
42
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -115,7 +116,7 @@ export const updateCollectionDocumentSystemFields = createServerFn({ method: 'PO
|
|
|
115
116
|
path?: string | null
|
|
116
117
|
/** Editorial advertised-locale set; omitted means no advertised-locale write. */
|
|
117
118
|
availableLocales?: string[]
|
|
118
|
-
}) => input
|
|
119
|
+
}) => validateDocumentWriteInput(input, 'metadata')
|
|
119
120
|
)
|
|
120
121
|
.handler(
|
|
121
122
|
withDocumentMutationErrors(async ({ data: input }) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BylineError,
|
|
3
3
|
ErrorCodes,
|
|
4
|
+
getDocumentFieldValidationDetails,
|
|
4
5
|
getDocumentRevisionValidationDetails,
|
|
5
6
|
getDocumentStaleDetails,
|
|
6
7
|
getLockConflictDetails,
|
|
@@ -27,6 +28,13 @@ export function documentMutationError(error: unknown): unknown {
|
|
|
27
28
|
message: 'Reload this document before making changes.',
|
|
28
29
|
details: validation,
|
|
29
30
|
}
|
|
31
|
+
const fields = getDocumentFieldValidationDetails(error)
|
|
32
|
+
if (fields)
|
|
33
|
+
return {
|
|
34
|
+
code: ErrorCodes.VALIDATION,
|
|
35
|
+
message: 'Some document fields are invalid.',
|
|
36
|
+
details: fields,
|
|
37
|
+
}
|
|
30
38
|
const lock = getLockConflictDetails(error)
|
|
31
39
|
if (lock)
|
|
32
40
|
return {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ERR_NOT_FOUND,
|
|
10
10
|
ERR_TREE_HOOK_COMMITTED,
|
|
11
11
|
ERR_VALIDATION,
|
|
12
|
+
getDocumentFieldValidationDetails,
|
|
12
13
|
getDocumentRevisionValidationDetails,
|
|
13
14
|
getDocumentStaleDetails,
|
|
14
15
|
getLockConflictDetails,
|
|
@@ -209,3 +210,43 @@ it('transports unavailable-document errors without provider details', async () =
|
|
|
209
210
|
})
|
|
210
211
|
expect(wire).not.toMatch(/private|internal/)
|
|
211
212
|
})
|
|
213
|
+
|
|
214
|
+
describe('field validation over the installed transport', () => {
|
|
215
|
+
it('retains stable field paths and messages while dropping diagnostics', async () => {
|
|
216
|
+
const issues = [
|
|
217
|
+
{ field: 'items[id=item-1].title', message: 'Title is required', kind: 'required' as const },
|
|
218
|
+
]
|
|
219
|
+
const { value, wire } = await roundTrip({}, async () => {
|
|
220
|
+
throw ERR_VALIDATION({
|
|
221
|
+
message: 'private detail',
|
|
222
|
+
details: { reason: 'invalid_document_fields', issues, sql: 'SELECT secret' },
|
|
223
|
+
cause: new Error('private cause'),
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
expect(getDocumentFieldValidationDetails(value.error)).toEqual({
|
|
227
|
+
reason: 'invalid_document_fields',
|
|
228
|
+
issues,
|
|
229
|
+
})
|
|
230
|
+
expect(wire).not.toMatch(/private detail|SELECT secret|private cause/)
|
|
231
|
+
expect(getDocumentRevisionValidationDetails(value.error)).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('never lets a malformed payload smuggle a waivable kind across the wire', async () => {
|
|
235
|
+
const { value } = await roundTrip({}, async () => {
|
|
236
|
+
throw ERR_VALIDATION({
|
|
237
|
+
message: 'private detail',
|
|
238
|
+
details: {
|
|
239
|
+
reason: 'invalid_document_fields',
|
|
240
|
+
issues: [
|
|
241
|
+
{ field: 'title', message: 'Title is required' },
|
|
242
|
+
{ field: 'summary', message: 'Summary is wrong', kind: 'bogus' },
|
|
243
|
+
],
|
|
244
|
+
},
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
expect(getDocumentFieldValidationDetails(value.error)?.issues.map((i) => i.kind)).toEqual([
|
|
248
|
+
'invalid',
|
|
249
|
+
'invalid',
|
|
250
|
+
])
|
|
251
|
+
})
|
|
252
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
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 { describe, expect, it } from 'vitest'
|
|
10
|
+
|
|
11
|
+
import { validateDocumentWriteInput } from './document-write-input.js'
|
|
12
|
+
|
|
13
|
+
describe('document write request shapes', () => {
|
|
14
|
+
it.each([null, {}, { collection: 'pages', data: [] }, { collection: 42, data: {} }])(
|
|
15
|
+
'rejects malformed create requests: %j',
|
|
16
|
+
(input) => {
|
|
17
|
+
expect(() => validateDocumentWriteInput(input, 'create')).toThrow(
|
|
18
|
+
'Invalid document write request'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
it.each([
|
|
23
|
+
null,
|
|
24
|
+
[null],
|
|
25
|
+
[{ kind: 'field.set', path: 'title[' }],
|
|
26
|
+
[{ kind: 'array.move', path: 'items', itemId: 'a', toIndex: -1 }],
|
|
27
|
+
[{ kind: 'unknown', path: 'title' }],
|
|
28
|
+
])('rejects malformed patches: %j', (patches) => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
validateDocumentWriteInput({ collection: 'pages', id: 'doc', patches }, 'patch')
|
|
31
|
+
).toThrow()
|
|
32
|
+
})
|
|
33
|
+
it('accepts nested patches and leaves revision checks to their dedicated contract', () => {
|
|
34
|
+
const input = {
|
|
35
|
+
collection: 'pages',
|
|
36
|
+
id: 'doc',
|
|
37
|
+
patches: [
|
|
38
|
+
{
|
|
39
|
+
kind: 'array.updateItem',
|
|
40
|
+
path: 'items',
|
|
41
|
+
itemId: 'a',
|
|
42
|
+
patches: [{ kind: 'field.set', path: 'title', value: 'Hello' }],
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
}
|
|
46
|
+
expect(validateDocumentWriteInput(input, 'patch')).toBe(input)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { ERR_VALIDATION, parseInstancePath } from '@byline/core'
|
|
10
|
+
|
|
11
|
+
const object = (value: unknown): value is Record<string, unknown> =>
|
|
12
|
+
value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
const text = (value: unknown): value is string =>
|
|
14
|
+
typeof value === 'string' && value.trim().length > 0
|
|
15
|
+
const index = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0
|
|
16
|
+
const path = (value: unknown) => text(value) && parseInstancePath(value).ok
|
|
17
|
+
|
|
18
|
+
function patches(value: unknown): boolean {
|
|
19
|
+
return (
|
|
20
|
+
Array.isArray(value) &&
|
|
21
|
+
value.every((patch) => {
|
|
22
|
+
if (!object(patch) || !path(patch.path)) return false
|
|
23
|
+
if (patch.opId !== undefined && !text(patch.opId)) return false
|
|
24
|
+
if (patch.timestamp !== undefined && !text(patch.timestamp)) return false
|
|
25
|
+
switch (patch.kind) {
|
|
26
|
+
case 'field.set':
|
|
27
|
+
return 'value' in patch
|
|
28
|
+
case 'field.clear':
|
|
29
|
+
return true
|
|
30
|
+
case 'array.insert':
|
|
31
|
+
return 'item' in patch && (patch.index === undefined || index(patch.index))
|
|
32
|
+
case 'array.move':
|
|
33
|
+
return text(patch.itemId) && index(patch.toIndex)
|
|
34
|
+
case 'array.remove':
|
|
35
|
+
return text(patch.itemId)
|
|
36
|
+
case 'array.updateItem':
|
|
37
|
+
return text(patch.itemId) && patches(patch.patches)
|
|
38
|
+
case 'block.add':
|
|
39
|
+
return text(patch.blockType) && (patch.index === undefined || index(patch.index))
|
|
40
|
+
case 'block.move':
|
|
41
|
+
return text(patch.blockId) && index(patch.toIndex)
|
|
42
|
+
case 'block.remove':
|
|
43
|
+
return text(patch.blockId)
|
|
44
|
+
case 'block.updateField':
|
|
45
|
+
return text(patch.blockId) && path(patch.fieldPath) && 'value' in patch
|
|
46
|
+
default:
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shape checks complement lifecycle authorization, revision and field validation. */
|
|
54
|
+
export function validateDocumentWriteInput<T>(input: T, mode: 'create' | 'patch' | 'metadata'): T {
|
|
55
|
+
const valid =
|
|
56
|
+
object(input) &&
|
|
57
|
+
text(input.collection) &&
|
|
58
|
+
(mode === 'create' ? object(input.data) : text(input.id)) &&
|
|
59
|
+
(input.locale === undefined || text(input.locale)) &&
|
|
60
|
+
(input.path == null || typeof input.path === 'string') &&
|
|
61
|
+
(input.availableLocales === undefined ||
|
|
62
|
+
(Array.isArray(input.availableLocales) && input.availableLocales.every(text))) &&
|
|
63
|
+
(mode !== 'patch' || patches(input.patches))
|
|
64
|
+
// Revision errors keep their existing, specialized lifecycle decoder.
|
|
65
|
+
if (!valid) throw ERR_VALIDATION({ message: 'Invalid document write request.' })
|
|
66
|
+
return input
|
|
67
|
+
}
|