ding-react-admin 2.0.0 → 2.0.5
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/LICENSE +21 -21
- package/README.md +254 -195
- package/dist/index.css +1 -0
- package/dist/index.js +1773 -1170
- package/dist/src/components/NavMenuLabel.d.ts +8 -0
- package/dist/src/components/NavMenuLabel.d.ts.map +1 -0
- package/dist/src/components/NavMenuSearch.d.ts +9 -0
- package/dist/src/components/NavMenuSearch.d.ts.map +1 -0
- package/dist/src/components/ScrollableArea.d.ts +10 -0
- package/dist/src/components/ScrollableArea.d.ts.map +1 -0
- package/dist/src/components/index.d.ts +3 -0
- package/dist/src/components/index.d.ts.map +1 -1
- package/dist/src/context/AuthProvider.d.ts +2 -0
- package/dist/src/context/AuthProvider.d.ts.map +1 -1
- package/dist/src/crud/FormGlobalErrorsAlert.d.ts +5 -0
- package/dist/src/crud/FormGlobalErrorsAlert.d.ts.map +1 -0
- package/dist/src/crud/ResourceForm.d.ts +1 -4
- package/dist/src/crud/ResourceForm.d.ts.map +1 -1
- package/dist/src/crud/ResourceFormModal.d.ts +7 -1
- package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
- package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
- package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
- package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
- package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
- package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
- package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
- package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
- package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
- package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
- package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
- package/dist/src/crud/index.d.ts +5 -0
- package/dist/src/crud/index.d.ts.map +1 -1
- package/dist/src/crud/types.d.ts +12 -0
- package/dist/src/crud/types.d.ts.map +1 -1
- package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
- package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
- package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
- package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
- package/dist/src/crud/utils/formErrors.d.ts +18 -7
- package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
- package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
- package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
- package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +1 -0
- package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -1
- package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
- package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
- package/dist/src/crud/utils/toFormData.d.ts +1 -1
- package/dist/src/crud/utils/toFormData.d.ts.map +1 -1
- package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
- package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
- package/dist/src/crud/utils/useChoices.d.ts +27 -1
- package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
- package/dist/src/crud/utils/useFormRecord.d.ts +9 -7
- package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -1
- package/dist/src/data/parseFormErrorHelpers.d.ts +6 -27
- package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/layouts/AdminLayout.d.ts +1 -1
- package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
- package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
- package/dist/src/layouts/navFilter.d.ts +5 -0
- package/dist/src/layouts/navFilter.d.ts.map +1 -0
- package/dist/src/layouts/navMenuItems.d.ts +6 -0
- package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
- package/dist/src/types.d.ts +12 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +85 -59
- package/src/app/AdminApp.tsx +34 -34
- package/src/components/AuthAlternateLink.tsx +23 -23
- package/src/components/DensitySwitch.tsx +31 -31
- package/src/components/NavMenuLabel.tsx +26 -0
- package/src/components/NavMenuSearch.tsx +76 -0
- package/src/components/ScrollableArea.tsx +46 -0
- package/src/components/ThemeSwitch.tsx +28 -28
- package/src/components/ThemeToolbar.tsx +34 -34
- package/src/components/index.ts +7 -4
- package/src/components/navMenu.css +21 -0
- package/src/components/scrollable.css +28 -0
- package/src/context/AppThemeProvider.tsx +128 -128
- package/src/context/AuthProvider.tsx +89 -77
- package/src/context/DataProvider.tsx +30 -30
- package/src/context/PermissionsProvider.tsx +57 -57
- package/src/crud/FilterBar.tsx +36 -36
- package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
- package/src/crud/FormSteps.tsx +135 -135
- package/src/crud/FormTabs.tsx +126 -126
- package/src/crud/InlineFormSet.tsx +162 -162
- package/src/crud/ListActionsBar.tsx +106 -106
- package/src/crud/ResourceForm.tsx +83 -160
- package/src/crud/ResourceFormModal.tsx +75 -133
- package/src/crud/ResourceRecordForm.tsx +166 -0
- package/src/crud/columns/BooleanColumn.tsx +32 -32
- package/src/crud/columns/CustomColumn.tsx +33 -33
- package/src/crud/columns/DateColumn.tsx +28 -28
- package/src/crud/columns/ImageColumn.tsx +58 -58
- package/src/crud/columns/NumberColumn.tsx +31 -31
- package/src/crud/columns/ReferenceColumn.tsx +85 -85
- package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
- package/src/crud/columns/TextColumn.tsx +38 -38
- package/src/crud/context/FilterContext.tsx +71 -71
- package/src/crud/context/FormContext.tsx +39 -39
- package/src/crud/context/FormSectionContext.tsx +22 -22
- package/src/crud/context/InlineFieldsRegistry.tsx +61 -61
- package/src/crud/context/ListContext.tsx +93 -93
- package/src/crud/fields/BooleanField.tsx +38 -38
- package/src/crud/fields/DateField.tsx +53 -53
- package/src/crud/fields/FileField.tsx +123 -123
- package/src/crud/fields/ImageField.tsx +120 -120
- package/src/crud/fields/NumberField.tsx +52 -52
- package/src/crud/fields/PasswordField.tsx +99 -99
- package/src/crud/fields/ReferenceField.tsx +237 -96
- package/src/crud/fields/ReferenceInputActions.tsx +106 -0
- package/src/crud/fields/ReferenceManyField.tsx +200 -76
- package/src/crud/fields/SelectField.tsx +48 -48
- package/src/crud/fields/TextField.tsx +46 -46
- package/src/crud/fields/uploadFieldUtils.ts +21 -21
- package/src/crud/fields/useUploadPreviewUrl.ts +20 -20
- package/src/crud/filters/BooleanFilter.tsx +38 -38
- package/src/crud/filters/DateFilter.tsx +35 -35
- package/src/crud/filters/NumberFilter.tsx +33 -33
- package/src/crud/filters/ReferenceFilter.tsx +192 -173
- package/src/crud/filters/SelectFilter.tsx +44 -44
- package/src/crud/filters/TextFilter.tsx +36 -36
- package/src/crud/index.ts +99 -94
- package/src/crud/types.ts +146 -134
- package/src/crud/utils/buildFormPayload.ts +24 -24
- package/src/crud/utils/buildInlineRowsPayload.ts +37 -37
- package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -29
- package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
- package/src/crud/utils/choiceSelectionUtils.ts +116 -0
- package/src/crud/utils/formErrors.test.ts +168 -0
- package/src/crud/utils/formErrors.ts +138 -42
- package/src/crud/utils/formSectionErrors.ts +85 -85
- package/src/crud/utils/getFormValue.ts +13 -13
- package/src/crud/utils/hasUploadValues.ts +9 -9
- package/src/crud/utils/nestedFieldPath.ts +8 -8
- package/src/crud/utils/prepareFormSubmitBody.ts +22 -17
- package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
- package/src/crud/utils/setFormValue.ts +18 -18
- package/src/crud/utils/sortQueryParam.ts +37 -37
- package/src/crud/utils/toFormData.ts +79 -81
- package/src/crud/utils/uploadReferenceUtils.ts +44 -0
- package/src/crud/utils/useAbortableEffect.ts +17 -17
- package/src/crud/utils/useChoices.ts +330 -182
- package/src/crud/utils/useFormRecord.ts +179 -156
- package/src/crud/utils/useListQueryState.ts +191 -191
- package/src/data/abortError.test.ts +19 -19
- package/src/data/abortError.ts +10 -10
- package/src/data/createMemoryResourceHandlers.ts +78 -78
- package/src/data/createRestResourceHandlers.ts +63 -63
- package/src/data/dataUtils.test.ts +228 -220
- package/src/data/inMemoryList.ts +97 -97
- package/src/data/parseFormErrorHelpers.test.ts +201 -138
- package/src/data/parseFormErrorHelpers.ts +376 -311
- package/src/data/sortHelpers.ts +48 -48
- package/src/index.ts +211 -207
- package/src/layouts/AdminLayout.tsx +530 -382
- package/src/layouts/AuthPageLayout.tsx +87 -81
- package/src/layouts/navFilter.ts +49 -0
- package/src/layouts/navMenuItems.tsx +44 -0
- package/src/pages/LoginPage.tsx +80 -80
- package/src/pages/PlaceholderPage.tsx +9 -9
- package/src/permissions/resourcePermissions.ts +47 -47
- package/src/router/createAdminRouter.tsx +64 -64
- package/src/router/guards.tsx +63 -63
- package/src/router/routeAccess.ts +89 -89
- package/src/types.ts +10 -0
- package/src/vite-env.d.ts +1 -1
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
|
|
2
|
-
import { buildFormPayload } from "./buildFormPayload";
|
|
3
|
-
import { buildInlineRowsPayload } from "./buildInlineRowsPayload";
|
|
4
|
-
import {
|
|
5
|
-
prepareFormSubmitBody,
|
|
6
|
-
type PrepareFormSubmitBodyOptions,
|
|
7
|
-
} from "./prepareFormSubmitBody";
|
|
8
|
-
|
|
9
|
-
/** Build the create/update body from form values, auto-switching to multipart when needed. */
|
|
10
|
-
export function buildResourceFormSubmitBody(
|
|
11
|
-
raw: Record<string, unknown>,
|
|
12
|
-
payloadFieldPaths: string[],
|
|
13
|
-
inlineRegistry?: Iterable<InlineFieldRegistration>,
|
|
14
|
-
options?: PrepareFormSubmitBodyOptions,
|
|
15
|
-
): Record<string, unknown> | FormData {
|
|
16
|
-
const payload = buildFormPayload(raw, payloadFieldPaths);
|
|
17
|
-
|
|
18
|
-
if (inlineRegistry) {
|
|
19
|
-
for (const inline of inlineRegistry) {
|
|
20
|
-
const rows = raw[inline.field];
|
|
21
|
-
const key = inline.payloadKey ?? inline.field;
|
|
22
|
-
payload[key] = buildInlineRowsPayload(rows, inline.sources, {
|
|
23
|
-
transformRows: inline.transformRows,
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
return prepareFormSubmitBody(payload, options);
|
|
29
|
-
}
|
|
1
|
+
import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
|
|
2
|
+
import { buildFormPayload } from "./buildFormPayload";
|
|
3
|
+
import { buildInlineRowsPayload } from "./buildInlineRowsPayload";
|
|
4
|
+
import {
|
|
5
|
+
prepareFormSubmitBody,
|
|
6
|
+
type PrepareFormSubmitBodyOptions,
|
|
7
|
+
} from "./prepareFormSubmitBody";
|
|
8
|
+
|
|
9
|
+
/** Build the create/update body from form values, auto-switching to multipart when needed. */
|
|
10
|
+
export function buildResourceFormSubmitBody(
|
|
11
|
+
raw: Record<string, unknown>,
|
|
12
|
+
payloadFieldPaths: string[],
|
|
13
|
+
inlineRegistry?: Iterable<InlineFieldRegistration>,
|
|
14
|
+
options?: PrepareFormSubmitBodyOptions,
|
|
15
|
+
): Record<string, unknown> | FormData {
|
|
16
|
+
const payload = buildFormPayload(raw, payloadFieldPaths);
|
|
17
|
+
|
|
18
|
+
if (inlineRegistry) {
|
|
19
|
+
for (const inline of inlineRegistry) {
|
|
20
|
+
const rows = raw[inline.field];
|
|
21
|
+
const key = inline.payloadKey ?? inline.field;
|
|
22
|
+
payload[key] = buildInlineRowsPayload(rows, inline.sources, {
|
|
23
|
+
transformRows: inline.transformRows,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return prepareFormSubmitBody(payload, options);
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
collectSelectedRecords,
|
|
4
|
+
isRecordObject,
|
|
5
|
+
normalizeSelectedIds,
|
|
6
|
+
recordsToOptions,
|
|
7
|
+
valueAsId,
|
|
8
|
+
valuesAsIds,
|
|
9
|
+
} from "./choiceSelectionUtils";
|
|
10
|
+
|
|
11
|
+
describe("choiceSelectionUtils", () => {
|
|
12
|
+
it("detects nested record objects", () => {
|
|
13
|
+
expect(isRecordObject({ id: 1, name: "Branch 1" }, "id")).toBe(true);
|
|
14
|
+
expect(isRecordObject("1", "id")).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts ids from primitive or nested values", () => {
|
|
18
|
+
expect(valueAsId("abc", "id")).toBe("abc");
|
|
19
|
+
expect(valueAsId({ id: 2, name: "Two" }, "id")).toBe(2);
|
|
20
|
+
expect(valuesAsIds([{ id: 1, name: "One" }, "2"], "id")).toEqual([1, "2"]);
|
|
21
|
+
expect(normalizeSelectedIds([{ id: 3, name: "Three" }], "id")).toEqual([3]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("collects embedded records from value and recordSource data", () => {
|
|
25
|
+
const records = collectSelectedRecords(
|
|
26
|
+
[{ id: 1, name: "One" }, "2"],
|
|
27
|
+
{ id: 3, name: "Three" },
|
|
28
|
+
"id",
|
|
29
|
+
);
|
|
30
|
+
expect(records).toHaveLength(2);
|
|
31
|
+
expect(records.map((record) => record.id).sort()).toEqual([1, 3]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("maps records to select options", () => {
|
|
35
|
+
expect(
|
|
36
|
+
recordsToOptions([{ id: 1, name: "Branch 1" }], "name", "id"),
|
|
37
|
+
).toEqual([
|
|
38
|
+
{
|
|
39
|
+
label: "Branch 1",
|
|
40
|
+
value: 1,
|
|
41
|
+
record: { id: 1, name: "Branch 1" },
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { ChoiceOption } from "../types";
|
|
2
|
+
|
|
3
|
+
export function isRecordObject(
|
|
4
|
+
value: unknown,
|
|
5
|
+
optionValue: string,
|
|
6
|
+
): value is Record<string, unknown> {
|
|
7
|
+
return (
|
|
8
|
+
typeof value === "object" &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
!Array.isArray(value) &&
|
|
11
|
+
optionValue in value
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function valueAsId(
|
|
16
|
+
value: unknown,
|
|
17
|
+
optionValue: string,
|
|
18
|
+
): string | number | undefined {
|
|
19
|
+
if (value == null || value === "") return undefined;
|
|
20
|
+
if (isRecordObject(value, optionValue)) {
|
|
21
|
+
const id = value[optionValue];
|
|
22
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
if (typeof value === "string" || typeof value === "number") return value;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function valuesAsIds(
|
|
30
|
+
values: unknown,
|
|
31
|
+
optionValue: string,
|
|
32
|
+
): (string | number)[] {
|
|
33
|
+
if (!Array.isArray(values)) return [];
|
|
34
|
+
return values
|
|
35
|
+
.map((value) => valueAsId(value, optionValue))
|
|
36
|
+
.filter((value): value is string | number => value != null);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeSelectedIds(
|
|
40
|
+
selectedValues: unknown | unknown[] | undefined,
|
|
41
|
+
optionValue: string,
|
|
42
|
+
): unknown[] {
|
|
43
|
+
if (selectedValues == null) return [];
|
|
44
|
+
const values = Array.isArray(selectedValues)
|
|
45
|
+
? selectedValues
|
|
46
|
+
: [selectedValues];
|
|
47
|
+
return values
|
|
48
|
+
.filter((value) => value != null && value !== "")
|
|
49
|
+
.map((value) =>
|
|
50
|
+
isRecordObject(value, optionValue) ? value[optionValue] : value,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function collectSelectedRecords(
|
|
55
|
+
selectedValues: unknown | unknown[] | undefined,
|
|
56
|
+
selectedRecords: Record<string, unknown> | Record<string, unknown>[] | undefined,
|
|
57
|
+
optionValue: string,
|
|
58
|
+
): Record<string, unknown>[] {
|
|
59
|
+
const records: Record<string, unknown>[] = [];
|
|
60
|
+
|
|
61
|
+
if (selectedRecords != null) {
|
|
62
|
+
if (Array.isArray(selectedRecords)) {
|
|
63
|
+
records.push(
|
|
64
|
+
...selectedRecords.filter(
|
|
65
|
+
(record): record is Record<string, unknown> =>
|
|
66
|
+
isRecordObject(record, optionValue),
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
} else if (isRecordObject(selectedRecords, optionValue)) {
|
|
70
|
+
records.push(selectedRecords);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (selectedValues == null) return records;
|
|
75
|
+
|
|
76
|
+
const values = Array.isArray(selectedValues)
|
|
77
|
+
? selectedValues
|
|
78
|
+
: [selectedValues];
|
|
79
|
+
for (const value of values) {
|
|
80
|
+
if (isRecordObject(value, optionValue)) {
|
|
81
|
+
records.push(value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return records;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveOptionLabel(
|
|
89
|
+
record: Record<string, unknown>,
|
|
90
|
+
optionLabel: string | ((record: Record<string, unknown>) => string),
|
|
91
|
+
): string {
|
|
92
|
+
if (typeof optionLabel === "function") return optionLabel(record);
|
|
93
|
+
return String(record[optionLabel] ?? "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function recordsToOptions(
|
|
97
|
+
records: Record<string, unknown>[],
|
|
98
|
+
optionLabel: string | ((record: Record<string, unknown>) => string),
|
|
99
|
+
optionValue: string,
|
|
100
|
+
): ChoiceOption[] {
|
|
101
|
+
return records.map((record) => ({
|
|
102
|
+
label: resolveOptionLabel(record, optionLabel),
|
|
103
|
+
value: record[optionValue],
|
|
104
|
+
record,
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function mergeOptions(
|
|
109
|
+
existing: ChoiceOption[],
|
|
110
|
+
incoming: ChoiceOption[],
|
|
111
|
+
): ChoiceOption[] {
|
|
112
|
+
const byValue = new Map<unknown, ChoiceOption>();
|
|
113
|
+
for (const option of existing) byValue.set(option.value, option);
|
|
114
|
+
for (const option of incoming) byValue.set(option.value, option);
|
|
115
|
+
return Array.from(byValue.values());
|
|
116
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { UseFormReturn } from "react-hook-form";
|
|
3
|
+
|
|
4
|
+
import type { DataProvider } from "../../data/dataProviderTypes";
|
|
5
|
+
import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
|
|
6
|
+
import {
|
|
7
|
+
applyApiErrorsToForm,
|
|
8
|
+
isKnownFormFieldPath,
|
|
9
|
+
partitionFormErrors,
|
|
10
|
+
} from "./formErrors";
|
|
11
|
+
|
|
12
|
+
const inlineRegistry = new Map<string, InlineFieldRegistration>([
|
|
13
|
+
[
|
|
14
|
+
"lines",
|
|
15
|
+
{
|
|
16
|
+
field: "lines",
|
|
17
|
+
sources: ["label", "quantity"],
|
|
18
|
+
},
|
|
19
|
+
],
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
describe("isKnownFormFieldPath", () => {
|
|
23
|
+
it("matches top-level payload fields", () => {
|
|
24
|
+
const payloadFields = new Set(["name", "email"]);
|
|
25
|
+
expect(isKnownFormFieldPath("name", payloadFields, inlineRegistry)).toBe(
|
|
26
|
+
true,
|
|
27
|
+
);
|
|
28
|
+
expect(
|
|
29
|
+
isKnownFormFieldPath("unknown", payloadFields, inlineRegistry),
|
|
30
|
+
).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("matches inline nested paths", () => {
|
|
34
|
+
const payloadFields = new Set(["lines"]);
|
|
35
|
+
expect(
|
|
36
|
+
isKnownFormFieldPath("lines.0.quantity", payloadFields, inlineRegistry),
|
|
37
|
+
).toBe(true);
|
|
38
|
+
expect(
|
|
39
|
+
isKnownFormFieldPath("lines.2.label", payloadFields, inlineRegistry),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
expect(
|
|
42
|
+
isKnownFormFieldPath("lines.0.missing", payloadFields, inlineRegistry),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("partitionFormErrors", () => {
|
|
48
|
+
it("keeps known fields and promotes unknown keys to global", () => {
|
|
49
|
+
const payloadFields = new Set(["name"]);
|
|
50
|
+
const { fieldErrors, globalErrors } = partitionFormErrors(
|
|
51
|
+
{
|
|
52
|
+
fields: {
|
|
53
|
+
name: "Required",
|
|
54
|
+
internal_code: "Already used",
|
|
55
|
+
},
|
|
56
|
+
global: ["Cannot save while locked"],
|
|
57
|
+
},
|
|
58
|
+
payloadFields,
|
|
59
|
+
inlineRegistry,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(fieldErrors).toEqual({ name: "Required" });
|
|
63
|
+
expect(globalErrors).toEqual([
|
|
64
|
+
"Cannot save while locked",
|
|
65
|
+
"Already used",
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("routes inline errors to fields when the column exists", () => {
|
|
70
|
+
const payloadFields = new Set(["lines"]);
|
|
71
|
+
const { fieldErrors, globalErrors } = partitionFormErrors(
|
|
72
|
+
{
|
|
73
|
+
fields: {
|
|
74
|
+
"lines.0.quantity": "Must be positive",
|
|
75
|
+
"lines.1.unknown": "Bad value",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
payloadFields,
|
|
79
|
+
inlineRegistry,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(fieldErrors).toEqual({
|
|
83
|
+
"lines.0.quantity": "Must be positive",
|
|
84
|
+
});
|
|
85
|
+
expect(globalErrors).toEqual(["Bad value"]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("applyApiErrorsToForm", () => {
|
|
90
|
+
const context = { resource: "users", mutation: "create" as const };
|
|
91
|
+
const options = {
|
|
92
|
+
payloadFields: new Set(["email"]),
|
|
93
|
+
inlineRegistry: [] as InlineFieldRegistration[],
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
function createForm() {
|
|
97
|
+
return {
|
|
98
|
+
setError: vi.fn(),
|
|
99
|
+
} as unknown as UseFormReturn<{ email: string }>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
it("maps standard validation bodies to field errors", async () => {
|
|
103
|
+
const form = createForm();
|
|
104
|
+
const dp = {
|
|
105
|
+
parseFormError: vi.fn().mockReturnValue({
|
|
106
|
+
fields: { email: "Already taken" },
|
|
107
|
+
}),
|
|
108
|
+
} as unknown as DataProvider;
|
|
109
|
+
|
|
110
|
+
const result = await applyApiErrorsToForm(
|
|
111
|
+
dp,
|
|
112
|
+
form,
|
|
113
|
+
{
|
|
114
|
+
body: { email: ["Already taken"] },
|
|
115
|
+
},
|
|
116
|
+
context,
|
|
117
|
+
options,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(result.handled).toBe(true);
|
|
121
|
+
expect(result.globalErrors).toEqual([]);
|
|
122
|
+
expect(form.setError).toHaveBeenCalledWith("email", {
|
|
123
|
+
type: "server",
|
|
124
|
+
message: "Already taken",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns a non-standard format warning when the parser cannot map the body", async () => {
|
|
129
|
+
const form = createForm();
|
|
130
|
+
const dp = {
|
|
131
|
+
parseFormError: vi.fn().mockReturnValue(null),
|
|
132
|
+
} as unknown as DataProvider;
|
|
133
|
+
|
|
134
|
+
const result = await applyApiErrorsToForm(
|
|
135
|
+
dp,
|
|
136
|
+
form,
|
|
137
|
+
{ body: { message: "Unexpected shape" } },
|
|
138
|
+
context,
|
|
139
|
+
options,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(result.handled).toBe(true);
|
|
143
|
+
expect(result.globalErrors[0]).toContain("Non-standard validation response.");
|
|
144
|
+
expect(result.globalErrors[0]).toContain('{"message":"Unexpected shape"}');
|
|
145
|
+
expect(form.setError).not.toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("returns a non-standard format warning for HTTP 400 without JSON", async () => {
|
|
149
|
+
const form = createForm();
|
|
150
|
+
const dp = {} as DataProvider;
|
|
151
|
+
const response = new Response("bad request", {
|
|
152
|
+
status: 400,
|
|
153
|
+
headers: { "Content-Type": "text/plain" },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const result = await applyApiErrorsToForm(
|
|
157
|
+
dp,
|
|
158
|
+
form,
|
|
159
|
+
{ response },
|
|
160
|
+
context,
|
|
161
|
+
options,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(result.handled).toBe(true);
|
|
165
|
+
expect(result.globalErrors[0]).toContain("Non-standard validation response.");
|
|
166
|
+
expect(result.globalErrors[0]).toContain("text/plain");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -1,42 +1,138 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
):
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
1
|
+
import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
DataProvider,
|
|
5
|
+
FormValidationErrors,
|
|
6
|
+
ParseFormErrorContext,
|
|
7
|
+
} from "../../data/dataProviderTypes";
|
|
8
|
+
import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
|
|
9
|
+
import {
|
|
10
|
+
describeNonStandardValidationBody,
|
|
11
|
+
getErrorResponseContentType,
|
|
12
|
+
getValidationErrorStatus,
|
|
13
|
+
resolveErrorBody,
|
|
14
|
+
} from "../../data/parseFormErrorHelpers";
|
|
15
|
+
|
|
16
|
+
export type ApplyApiErrorsResult = {
|
|
17
|
+
handled: boolean;
|
|
18
|
+
globalErrors: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ApplyApiErrorsOptions = {
|
|
22
|
+
payloadFields: Iterable<string>;
|
|
23
|
+
inlineRegistry: Iterable<InlineFieldRegistration>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function toMessages(value: string | string[] | undefined): string[] {
|
|
27
|
+
if (!value) return [];
|
|
28
|
+
return Array.isArray(value) ? value : [value];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function messageText(msg: string | string[]): string {
|
|
32
|
+
return Array.isArray(msg) ? msg.join(", ") : msg;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Top-level `source` or inline cell path such as `lines.0.quantity`. */
|
|
36
|
+
export function isKnownFormFieldPath(
|
|
37
|
+
path: string,
|
|
38
|
+
payloadFields: Set<string>,
|
|
39
|
+
inlineRegistry: Map<string, InlineFieldRegistration>,
|
|
40
|
+
): boolean {
|
|
41
|
+
if (payloadFields.has(path)) return true;
|
|
42
|
+
|
|
43
|
+
const match = path.match(/^([^.]+)\.(\d+)\.([^.]+)$/);
|
|
44
|
+
if (!match) return false;
|
|
45
|
+
|
|
46
|
+
const [, arrayName, , source] = match;
|
|
47
|
+
return inlineRegistry.get(arrayName)?.sources.includes(source) ?? false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function partitionFormErrors(
|
|
51
|
+
parsed: FormValidationErrors,
|
|
52
|
+
payloadFields: Set<string>,
|
|
53
|
+
inlineRegistry: Map<string, InlineFieldRegistration>,
|
|
54
|
+
): { fieldErrors: Record<string, string | string[]>; globalErrors: string[] } {
|
|
55
|
+
const fieldErrors: Record<string, string | string[]> = {};
|
|
56
|
+
const globalErrors = [...toMessages(parsed.global)];
|
|
57
|
+
|
|
58
|
+
for (const [path, msg] of Object.entries(parsed.fields ?? {})) {
|
|
59
|
+
if (isKnownFormFieldPath(path, payloadFields, inlineRegistry)) {
|
|
60
|
+
fieldErrors[path] = msg;
|
|
61
|
+
} else {
|
|
62
|
+
globalErrors.push(messageText(msg));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { fieldErrors, globalErrors };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function applyFormErrors<T extends FieldValues>(
|
|
70
|
+
form: UseFormReturn<T>,
|
|
71
|
+
fieldErrors: Record<string, string | string[]>,
|
|
72
|
+
) {
|
|
73
|
+
for (const [path, msg] of Object.entries(fieldErrors)) {
|
|
74
|
+
form.setError(path as FieldPath<T>, {
|
|
75
|
+
type: "server",
|
|
76
|
+
message: messageText(msg),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function nonStandardValidationHint(error: unknown): string | undefined {
|
|
82
|
+
const contentType = getErrorResponseContentType(error);
|
|
83
|
+
if (contentType && !/application\/json/i.test(contentType)) {
|
|
84
|
+
return `non-JSON response (Content-Type: ${contentType})`;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function applyApiErrorsToForm<T extends FieldValues>(
|
|
90
|
+
dp: DataProvider,
|
|
91
|
+
form: UseFormReturn<T>,
|
|
92
|
+
error: unknown,
|
|
93
|
+
context: ParseFormErrorContext,
|
|
94
|
+
options: ApplyApiErrorsOptions,
|
|
95
|
+
): Promise<ApplyApiErrorsResult> {
|
|
96
|
+
const body = await resolveErrorBody(error);
|
|
97
|
+
|
|
98
|
+
if (body != null) {
|
|
99
|
+
const parsed = dp.parseFormError?.({ body }, context);
|
|
100
|
+
|
|
101
|
+
if (parsed) {
|
|
102
|
+
const payloadFields = new Set(options.payloadFields);
|
|
103
|
+
const inlineRegistry = new Map<string, InlineFieldRegistration>();
|
|
104
|
+
for (const inline of options.inlineRegistry) {
|
|
105
|
+
inlineRegistry.set(inline.field, inline);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { fieldErrors, globalErrors } = partitionFormErrors(
|
|
109
|
+
parsed,
|
|
110
|
+
payloadFields,
|
|
111
|
+
inlineRegistry,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (Object.keys(fieldErrors).length || globalErrors.length) {
|
|
115
|
+
applyFormErrors(form, fieldErrors);
|
|
116
|
+
return { handled: true, globalErrors };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
handled: true,
|
|
122
|
+
globalErrors: [describeNonStandardValidationBody(body)],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (getValidationErrorStatus(error) != null) {
|
|
127
|
+
return {
|
|
128
|
+
handled: true,
|
|
129
|
+
globalErrors: [
|
|
130
|
+
describeNonStandardValidationBody(null, {
|
|
131
|
+
hint: nonStandardValidationHint(error),
|
|
132
|
+
}),
|
|
133
|
+
],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return { handled: false, globalErrors: [] };
|
|
138
|
+
}
|