ding-react-admin 1.0.2 → 2.0.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/README.md +38 -13
- package/dist/index.js +1317 -1060
- package/dist/src/crud/InlineFormSet.d.ts +12 -25
- package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
- package/dist/src/crud/ResourceForm.d.ts +7 -4
- package/dist/src/crud/ResourceForm.d.ts.map +1 -1
- package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
- package/dist/src/crud/ResourceList.d.ts.map +1 -1
- package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
- package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
- package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
- package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
- package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
- package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
- package/dist/src/crud/fields/BooleanField.d.ts +3 -1
- package/dist/src/crud/fields/BooleanField.d.ts.map +1 -1
- package/dist/src/crud/fields/DateField.d.ts +3 -1
- package/dist/src/crud/fields/DateField.d.ts.map +1 -1
- package/dist/src/crud/fields/FieldWrapper.d.ts +7 -14
- package/dist/src/crud/fields/FieldWrapper.d.ts.map +1 -1
- package/dist/src/crud/fields/FileField.d.ts +12 -0
- package/dist/src/crud/fields/FileField.d.ts.map +1 -0
- package/dist/src/crud/fields/ImageField.d.ts +14 -0
- package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
- package/dist/src/crud/fields/NumberField.d.ts +5 -3
- package/dist/src/crud/fields/NumberField.d.ts.map +1 -1
- package/dist/src/crud/fields/PasswordField.d.ts +5 -3
- package/dist/src/crud/fields/PasswordField.d.ts.map +1 -1
- package/dist/src/crud/fields/ReferenceField.d.ts +6 -5
- package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
- package/dist/src/crud/fields/ReferenceManyField.d.ts +3 -1
- package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
- package/dist/src/crud/fields/SelectField.d.ts +3 -1
- package/dist/src/crud/fields/SelectField.d.ts.map +1 -1
- package/dist/src/crud/fields/TextField.d.ts +5 -3
- package/dist/src/crud/fields/TextField.d.ts.map +1 -1
- package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
- package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
- package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
- package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
- package/dist/src/crud/index.d.ts +26 -9
- package/dist/src/crud/index.d.ts.map +1 -1
- package/dist/src/crud/types.d.ts +23 -20
- package/dist/src/crud/types.d.ts.map +1 -1
- package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
- package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
- package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
- package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
- package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
- package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
- package/dist/src/crud/utils/formErrors.d.ts +1 -1
- package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
- package/dist/src/crud/utils/getFormValue.d.ts +3 -0
- package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
- package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
- package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
- package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
- package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
- package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +7 -0
- package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
- package/dist/src/crud/utils/setFormValue.d.ts +3 -0
- package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
- package/dist/src/crud/utils/toFormData.d.ts +13 -0
- package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
- package/dist/src/crud/utils/useAbortableEffect.d.ts +7 -0
- package/dist/src/crud/utils/useAbortableEffect.d.ts.map +1 -0
- package/dist/src/crud/utils/useFormRecord.d.ts +42 -0
- package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
- package/dist/src/data/abortError.d.ts +3 -0
- package/dist/src/data/abortError.d.ts.map +1 -0
- package/dist/src/data/abortError.test.d.ts +2 -0
- package/dist/src/data/abortError.test.d.ts.map +1 -0
- package/dist/src/data/createRestResourceHandlers.d.ts +6 -6
- package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
- package/dist/src/data/dataProviderTypes.d.ts +13 -6
- package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
- package/dist/src/data/parseFormErrorHelpers.d.ts +44 -6
- package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
- package/dist/src/data/resourceHandlers.d.ts +3 -3
- package/dist/src/data/resourceHandlers.d.ts.map +1 -1
- package/dist/src/index.d.ts +5 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
- package/package.json +59 -59
- package/src/crud/InlineFormSet.tsx +90 -159
- package/src/crud/ResourceForm.tsx +83 -204
- package/src/crud/ResourceFormModal.tsx +57 -45
- package/src/crud/ResourceList.tsx +36 -26
- package/src/crud/columns/ImageColumn.tsx +58 -0
- package/src/crud/columns/TextColumn.tsx +2 -2
- package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
- package/src/crud/context/PayloadFieldsContext.tsx +62 -0
- package/src/crud/fields/BooleanField.tsx +22 -16
- package/src/crud/fields/DateField.tsx +32 -26
- package/src/crud/fields/FieldWrapper.tsx +87 -109
- package/src/crud/fields/FileField.tsx +123 -0
- package/src/crud/fields/ImageField.tsx +120 -0
- package/src/crud/fields/NumberField.tsx +36 -34
- package/src/crud/fields/PasswordField.tsx +41 -40
- package/src/crud/fields/ReferenceField.tsx +35 -130
- package/src/crud/fields/ReferenceManyField.tsx +31 -25
- package/src/crud/fields/SelectField.tsx +6 -0
- package/src/crud/fields/TextField.tsx +32 -30
- package/src/crud/fields/uploadFieldUtils.ts +21 -0
- package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
- package/src/crud/index.ts +35 -14
- package/src/crud/types.ts +134 -130
- package/src/crud/utils/buildFormPayload.ts +24 -0
- package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
- package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
- package/src/crud/utils/formErrors.ts +42 -38
- package/src/crud/utils/{getByPath.ts → getFormValue.ts} +2 -2
- package/src/crud/utils/hasUploadValues.ts +9 -0
- package/src/crud/utils/nestedFieldPath.ts +8 -0
- package/src/crud/utils/prepareFormSubmitBody.ts +17 -0
- package/src/crud/utils/{setByPath.ts → setFormValue.ts} +2 -2
- package/src/crud/utils/toFormData.ts +81 -0
- package/src/crud/utils/useAbortableEffect.ts +17 -0
- package/src/crud/utils/useFormRecord.ts +156 -0
- package/src/data/abortError.test.ts +19 -0
- package/src/data/abortError.ts +10 -0
- package/src/data/createMemoryResourceHandlers.ts +1 -1
- package/src/data/createRestResourceHandlers.ts +22 -16
- package/src/data/dataProviderTypes.ts +15 -5
- package/src/data/dataUtils.test.ts +118 -6
- package/src/data/parseFormErrorHelpers.test.ts +138 -84
- package/src/data/parseFormErrorHelpers.ts +311 -224
- package/src/data/resourceHandlers.ts +5 -4
- package/src/index.ts +34 -9
- package/src/layouts/AdminLayout.tsx +2 -1
- package/dist/src/crud/context/FormFieldsContext.d.ts +0 -14
- package/dist/src/crud/context/FormFieldsContext.d.ts.map +0 -1
- package/dist/src/crud/context/InlineFormContext.d.ts +0 -16
- package/dist/src/crud/context/InlineFormContext.d.ts.map +0 -1
- package/dist/src/crud/fields/useInlineOrFormField.d.ts +0 -10
- package/dist/src/crud/fields/useInlineOrFormField.d.ts.map +0 -1
- package/dist/src/crud/utils/getByPath.d.ts +0 -3
- package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
- package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
- package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
- package/dist/src/crud/utils/pickBySources.d.ts +0 -6
- package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
- package/dist/src/crud/utils/setByPath.d.ts +0 -3
- package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
- package/src/crud/context/FormFieldsContext.tsx +0 -76
- package/src/crud/context/InlineFormContext.tsx +0 -71
- package/src/crud/fields/useInlineOrFormField.tsx +0 -91
- package/src/crud/utils/inlineArrayName.ts +0 -4
- package/src/crud/utils/pickBySources.ts +0 -24
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Whether a save payload contains a `File` or `Blob` anywhere (including inline rows). */
|
|
2
|
+
export function hasUploadValues(value: unknown): boolean {
|
|
3
|
+
if (value instanceof Blob) return true;
|
|
4
|
+
if (Array.isArray(value)) return value.some(hasUploadValues);
|
|
5
|
+
if (value && typeof value === "object") {
|
|
6
|
+
return Object.values(value as Record<string, unknown>).some(hasUploadValues);
|
|
7
|
+
}
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { hasUploadValues } from "./hasUploadValues";
|
|
2
|
+
import { toFormData, type ToFormDataOptions } from "./toFormData";
|
|
3
|
+
|
|
4
|
+
export type PrepareFormSubmitBodyOptions = ToFormDataOptions;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* When the payload contains any `File`/`Blob`, return `FormData`; otherwise the plain object.
|
|
8
|
+
*/
|
|
9
|
+
export function prepareFormSubmitBody(
|
|
10
|
+
payload: Record<string, unknown>,
|
|
11
|
+
options?: PrepareFormSubmitBodyOptions,
|
|
12
|
+
): Record<string, unknown> | FormData {
|
|
13
|
+
if (hasUploadValues(payload)) {
|
|
14
|
+
return toFormData(payload, options);
|
|
15
|
+
}
|
|
16
|
+
return payload;
|
|
17
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/** Write a value into
|
|
2
|
-
export function
|
|
1
|
+
/** Write a value into an object using dot notation, e.g. `invoiceLine.product`. */
|
|
2
|
+
export function setFormValue(
|
|
3
3
|
record: Record<string, unknown>,
|
|
4
4
|
path: string,
|
|
5
5
|
value: unknown,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export type ToFormDataOptions = {
|
|
2
|
+
/**
|
|
3
|
+
* Omit unchanged upload URL strings (`http://` / `https://`) from the body.
|
|
4
|
+
* Default `true` — edit forms keep existing files without re-sending the URL.
|
|
5
|
+
*/
|
|
6
|
+
skipExistingUploadUrls?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function isExistingUploadUrl(value: string): boolean {
|
|
10
|
+
return /^https?:\/\//i.test(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function appendFormDataValue(
|
|
14
|
+
formData: FormData,
|
|
15
|
+
key: string,
|
|
16
|
+
value: unknown,
|
|
17
|
+
options: Required<ToFormDataOptions>,
|
|
18
|
+
): void {
|
|
19
|
+
if (value === undefined) return;
|
|
20
|
+
|
|
21
|
+
if (value === null) {
|
|
22
|
+
formData.append(key, "");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (value instanceof Blob) {
|
|
27
|
+
formData.append(key, value);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
32
|
+
formData.append(key, String(value));
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === "string") {
|
|
37
|
+
if (options.skipExistingUploadUrls && isExistingUploadUrl(value)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
formData.append(key, value);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(value)) {
|
|
45
|
+
value.forEach((item, index) => {
|
|
46
|
+
appendFormDataValue(formData, `${key}[${index}]`, item, options);
|
|
47
|
+
});
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (typeof value === "object") {
|
|
52
|
+
for (const [childKey, childValue] of Object.entries(
|
|
53
|
+
value as Record<string, unknown>,
|
|
54
|
+
)) {
|
|
55
|
+
appendFormDataValue(formData, `${key}[${childKey}]`, childValue, options);
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
formData.append(key, String(value));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Flatten a save payload into `FormData` using bracket notation for nested fields
|
|
65
|
+
* and arrays (e.g. `lines[0][label]`, `address[city]`).
|
|
66
|
+
*/
|
|
67
|
+
export function toFormData(
|
|
68
|
+
data: Record<string, unknown>,
|
|
69
|
+
options?: ToFormDataOptions,
|
|
70
|
+
): FormData {
|
|
71
|
+
const resolved: Required<ToFormDataOptions> = {
|
|
72
|
+
skipExistingUploadUrls: options?.skipExistingUploadUrls ?? true,
|
|
73
|
+
};
|
|
74
|
+
const formData = new FormData();
|
|
75
|
+
|
|
76
|
+
for (const [key, value] of Object.entries(data)) {
|
|
77
|
+
appendFormDataValue(formData, key, value, resolved);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return formData;
|
|
81
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useEffect, type DependencyList } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Runs an async effect with an `AbortSignal` that aborts on cleanup.
|
|
5
|
+
* Use with `params.signal` on DataProvider reads and `isAbortError` in catch blocks.
|
|
6
|
+
*/
|
|
7
|
+
export function useAbortableEffect(
|
|
8
|
+
effect: (signal: AbortSignal) => void | Promise<void>,
|
|
9
|
+
deps: DependencyList,
|
|
10
|
+
): void {
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
void effect(controller.signal);
|
|
14
|
+
return () => controller.abort();
|
|
15
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
16
|
+
}, deps);
|
|
17
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { MessageInstance } from "antd/es/message/interface";
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useState,
|
|
5
|
+
type MutableRefObject,
|
|
6
|
+
} from "react";
|
|
7
|
+
import type { NavigateFunction } from "react-router-dom";
|
|
8
|
+
import type { DataProvider } from "../../data/dataProviderTypes";
|
|
9
|
+
import { isAbortError } from "../../data/abortError";
|
|
10
|
+
import {
|
|
11
|
+
type DefaultValues,
|
|
12
|
+
type FieldValues,
|
|
13
|
+
type UseFormReturn,
|
|
14
|
+
} from "react-hook-form";
|
|
15
|
+
import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
|
|
16
|
+
import { buildResourceFormSubmitBody } from "./buildResourceFormSubmitBody";
|
|
17
|
+
import { applyApiErrorsToForm } from "./formErrors";
|
|
18
|
+
import { useAbortableEffect } from "./useAbortableEffect";
|
|
19
|
+
|
|
20
|
+
type LoadOptions<T extends FieldValues> = {
|
|
21
|
+
dp: DataProvider;
|
|
22
|
+
resource: string;
|
|
23
|
+
id: string | undefined;
|
|
24
|
+
isNew: boolean;
|
|
25
|
+
form: UseFormReturn<T>;
|
|
26
|
+
message: MessageInstance;
|
|
27
|
+
defaultValues?: Partial<T>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Edit mode: `getOne` → `form.reset` → bump `formVersion` so field arrays remount. */
|
|
31
|
+
export function useFormRecordLoad<T extends FieldValues>({
|
|
32
|
+
dp,
|
|
33
|
+
resource,
|
|
34
|
+
id,
|
|
35
|
+
isNew,
|
|
36
|
+
form,
|
|
37
|
+
message,
|
|
38
|
+
defaultValues,
|
|
39
|
+
}: LoadOptions<T>) {
|
|
40
|
+
const [loading, setLoading] = useState(!isNew);
|
|
41
|
+
const [formVersion, setFormVersion] = useState(0);
|
|
42
|
+
|
|
43
|
+
const load = useCallback(
|
|
44
|
+
async (signal?: AbortSignal) => {
|
|
45
|
+
if (isNew || !id) {
|
|
46
|
+
if (defaultValues) {
|
|
47
|
+
form.reset({ ...defaultValues } as DefaultValues<T>);
|
|
48
|
+
}
|
|
49
|
+
setLoading(false);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
const res = await dp.getOne(resource, id, { signal });
|
|
55
|
+
if (signal?.aborted) return;
|
|
56
|
+
form.reset(res.data as DefaultValues<T>);
|
|
57
|
+
setFormVersion((v) => v + 1);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
if (!isAbortError(e)) {
|
|
60
|
+
message.error(e instanceof Error ? e.message : "Load failed");
|
|
61
|
+
}
|
|
62
|
+
} finally {
|
|
63
|
+
if (!signal?.aborted) setLoading(false);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[dp, resource, id, isNew, form, message, defaultValues],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
useAbortableEffect((signal) => load(signal), [load]);
|
|
70
|
+
|
|
71
|
+
return { loading, formVersion };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SubmitOptions<T extends FieldValues & { id?: unknown }> = {
|
|
75
|
+
dp: DataProvider;
|
|
76
|
+
resource: string;
|
|
77
|
+
id: string | undefined;
|
|
78
|
+
isNew: boolean;
|
|
79
|
+
form: UseFormReturn<T>;
|
|
80
|
+
message: MessageInstance;
|
|
81
|
+
navigate: NavigateFunction;
|
|
82
|
+
listPath: string;
|
|
83
|
+
payloadFieldsRef: MutableRefObject<Set<string>>;
|
|
84
|
+
inlineRegistryRef: MutableRefObject<Map<string, InlineFieldRegistration>>;
|
|
85
|
+
onSaved?: (record: T) => void;
|
|
86
|
+
stayOnPage?: boolean;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Save: `buildFormPayload` → clean inline rows → one `create` or `update`. */
|
|
90
|
+
export function useFormRecordSave<T extends FieldValues & { id?: unknown }>({
|
|
91
|
+
dp,
|
|
92
|
+
resource,
|
|
93
|
+
id,
|
|
94
|
+
isNew,
|
|
95
|
+
form,
|
|
96
|
+
message,
|
|
97
|
+
navigate,
|
|
98
|
+
listPath,
|
|
99
|
+
payloadFieldsRef,
|
|
100
|
+
inlineRegistryRef,
|
|
101
|
+
onSaved,
|
|
102
|
+
stayOnPage,
|
|
103
|
+
}: SubmitOptions<T>) {
|
|
104
|
+
return useCallback(
|
|
105
|
+
async (values: T) => {
|
|
106
|
+
try {
|
|
107
|
+
const raw = values as Record<string, unknown>;
|
|
108
|
+
const body = buildResourceFormSubmitBody(
|
|
109
|
+
raw,
|
|
110
|
+
Array.from(payloadFieldsRef.current),
|
|
111
|
+
inlineRegistryRef.current.values(),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
let saved: T;
|
|
115
|
+
if (isNew) {
|
|
116
|
+
const res = await dp.create(resource, body);
|
|
117
|
+
saved = res.data as T;
|
|
118
|
+
message.success("Created");
|
|
119
|
+
} else if (id) {
|
|
120
|
+
const res = await dp.update(resource, { id, data: body });
|
|
121
|
+
saved = res.data as T;
|
|
122
|
+
message.success("Updated");
|
|
123
|
+
} else {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
onSaved?.(saved);
|
|
128
|
+
if (!stayOnPage) navigate(listPath);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
const inlineFieldPaths = Array.from(inlineRegistryRef.current.keys());
|
|
131
|
+
const handled = await applyApiErrorsToForm(dp, form, message, e, {
|
|
132
|
+
resource,
|
|
133
|
+
mutation: isNew ? "create" : "update",
|
|
134
|
+
inlineFieldPaths,
|
|
135
|
+
});
|
|
136
|
+
if (!handled) {
|
|
137
|
+
message.error(e instanceof Error ? e.message : "Save failed");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[
|
|
142
|
+
dp,
|
|
143
|
+
resource,
|
|
144
|
+
id,
|
|
145
|
+
isNew,
|
|
146
|
+
form,
|
|
147
|
+
message,
|
|
148
|
+
navigate,
|
|
149
|
+
listPath,
|
|
150
|
+
payloadFieldsRef,
|
|
151
|
+
inlineRegistryRef,
|
|
152
|
+
onSaved,
|
|
153
|
+
stayOnPage,
|
|
154
|
+
],
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isAbortError } from "./abortError";
|
|
3
|
+
|
|
4
|
+
describe("isAbortError", () => {
|
|
5
|
+
it("detects DOM AbortError", () => {
|
|
6
|
+
expect(isAbortError({ name: "AbortError" })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("detects axios CanceledError", () => {
|
|
10
|
+
expect(isAbortError({ name: "CanceledError", code: "ERR_CANCELED" })).toBe(
|
|
11
|
+
true,
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("returns false for ordinary errors", () => {
|
|
16
|
+
expect(isAbortError(new Error("Network Error"))).toBe(false);
|
|
17
|
+
expect(isAbortError(null)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** True when a fetch/axios call was aborted via `AbortSignal` (not a real failure). */
|
|
2
|
+
export function isAbortError(error: unknown): boolean {
|
|
3
|
+
if (error == null || typeof error !== "object") return false;
|
|
4
|
+
const e = error as { name?: string; code?: string };
|
|
5
|
+
return (
|
|
6
|
+
e.name === "AbortError" ||
|
|
7
|
+
e.name === "CanceledError" ||
|
|
8
|
+
e.code === "ERR_CANCELED"
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -48,7 +48,7 @@ export function createMemoryResourceHandlers<
|
|
|
48
48
|
return applyInMemoryListParams(asRows(base), params) as GetListResult<T>;
|
|
49
49
|
},
|
|
50
50
|
|
|
51
|
-
async getOne(id): Promise<GetOneResult<T>> {
|
|
51
|
+
async getOne(id, _params?): Promise<GetOneResult<T>> {
|
|
52
52
|
return { data: getById(config.getRows(), id) as T };
|
|
53
53
|
},
|
|
54
54
|
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import type { GetListParams, Identifier } from "./dataProviderTypes";
|
|
1
|
+
import type { GetListParams, GetOneParams, Identifier, FormMutationBody } from "./dataProviderTypes";
|
|
2
2
|
import type { ResourceHandlers } from "./resourceHandlers";
|
|
3
3
|
|
|
4
4
|
export type RestResourceHandlersConfig<
|
|
5
5
|
T extends Record<string, unknown> = Record<string, unknown>,
|
|
6
6
|
> = {
|
|
7
7
|
list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
|
|
8
|
-
retrieve: (id: Identifier) => Promise<T>;
|
|
9
|
-
create: (data:
|
|
10
|
-
update: (id: Identifier, data:
|
|
8
|
+
retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
|
|
9
|
+
create: (data: FormMutationBody) => Promise<T>;
|
|
10
|
+
update: (id: Identifier, data: FormMutationBody) => Promise<T>;
|
|
11
11
|
destroy: (id: Identifier) => Promise<void>;
|
|
12
|
-
/** When API create body differs from form `source` paths. */
|
|
12
|
+
/** When API create body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
13
13
|
transformCreate?: (data: Record<string, unknown>) => unknown;
|
|
14
|
-
/** When API update body differs from form `source` paths. */
|
|
14
|
+
/** When API update body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
15
15
|
transformUpdate?: (data: Record<string, unknown>) => unknown;
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -27,25 +27,31 @@ export function createRestResourceHandlers<
|
|
|
27
27
|
return config.list(params);
|
|
28
28
|
},
|
|
29
29
|
|
|
30
|
-
async getOne(id) {
|
|
31
|
-
return { data: await config.retrieve(id) };
|
|
30
|
+
async getOne(id, params) {
|
|
31
|
+
return { data: await config.retrieve(id, params) };
|
|
32
32
|
},
|
|
33
33
|
|
|
34
34
|
async create(data) {
|
|
35
|
-
const body =
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
const body =
|
|
36
|
+
data instanceof FormData
|
|
37
|
+
? data
|
|
38
|
+
: config.transformCreate
|
|
39
|
+
? config.transformCreate(data as Record<string, unknown>)
|
|
40
|
+
: data;
|
|
38
41
|
return {
|
|
39
|
-
data: await config.create(body as
|
|
42
|
+
data: await config.create(body as FormMutationBody),
|
|
40
43
|
};
|
|
41
44
|
},
|
|
42
45
|
|
|
43
46
|
async update({ id, data }) {
|
|
44
|
-
const body =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const body =
|
|
48
|
+
data instanceof FormData
|
|
49
|
+
? data
|
|
50
|
+
: config.transformUpdate
|
|
51
|
+
? config.transformUpdate(data as Record<string, unknown>)
|
|
52
|
+
: data;
|
|
47
53
|
return {
|
|
48
|
-
data: await config.update(id, body as
|
|
54
|
+
data: await config.update(id, body as FormMutationBody),
|
|
49
55
|
};
|
|
50
56
|
},
|
|
51
57
|
|
|
@@ -13,12 +13,19 @@ export type PaginationParams = {
|
|
|
13
13
|
perPage: number;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
+
export type GetOneParams = {
|
|
17
|
+
/** Forward to fetch/axios to cancel in-flight reads when the UI unmounts or deps change. */
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
};
|
|
20
|
+
|
|
16
21
|
export type GetListParams = {
|
|
17
22
|
pagination?: PaginationParams;
|
|
18
23
|
/** Single or multi-column sort. */
|
|
19
24
|
sort?: SortSpec | SortSpec[];
|
|
20
25
|
/** Filter values; arrays mean "match any of" for that field. */
|
|
21
26
|
filter?: Record<string, unknown>;
|
|
27
|
+
/** Forward to fetch/axios to cancel in-flight reads when the UI unmounts or deps change. */
|
|
28
|
+
signal?: AbortSignal;
|
|
22
29
|
};
|
|
23
30
|
|
|
24
31
|
export type GetListResult<RecordType extends Record<string, unknown>> = {
|
|
@@ -36,7 +43,7 @@ export type CreateResult<RecordType extends Record<string, unknown>> = {
|
|
|
36
43
|
|
|
37
44
|
export type UpdateParams<RecordType extends Record<string, unknown>> = {
|
|
38
45
|
id: Identifier;
|
|
39
|
-
data: Partial<RecordType
|
|
46
|
+
data: Partial<RecordType> | FormData;
|
|
40
47
|
};
|
|
41
48
|
|
|
42
49
|
export type UpdateResult<RecordType extends Record<string, unknown>> = {
|
|
@@ -47,14 +54,16 @@ export type DeleteResult<RecordType extends Record<string, unknown>> = {
|
|
|
47
54
|
data: RecordType | null;
|
|
48
55
|
};
|
|
49
56
|
|
|
57
|
+
/** Save body from forms — plain JSON object or multipart `FormData` when uploads are present. */
|
|
58
|
+
export type FormMutationBody = Record<string, unknown> | FormData;
|
|
59
|
+
|
|
50
60
|
export type FormMutation = "create" | "update";
|
|
51
61
|
|
|
52
62
|
export type ParseFormErrorContext = {
|
|
53
63
|
resource: string;
|
|
54
64
|
mutation: FormMutation;
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
rowIndex?: number;
|
|
65
|
+
/** Top-level inline field-array paths on the form, e.g. `["lines"]`. */
|
|
66
|
+
inlineFieldPaths?: string[];
|
|
58
67
|
};
|
|
59
68
|
|
|
60
69
|
/** Field paths match react-hook-form `name` (form `source` or inline `arrayName.index.source`). */
|
|
@@ -83,10 +92,11 @@ export type DataProvider<
|
|
|
83
92
|
getOne: (
|
|
84
93
|
resource: string,
|
|
85
94
|
id: Identifier,
|
|
95
|
+
params?: GetOneParams,
|
|
86
96
|
) => Promise<GetOneResult<RecordType>>;
|
|
87
97
|
create: (
|
|
88
98
|
resource: string,
|
|
89
|
-
data: Partial<RecordType
|
|
99
|
+
data: Partial<RecordType> | FormData,
|
|
90
100
|
) => Promise<CreateResult<RecordType>>;
|
|
91
101
|
update: (
|
|
92
102
|
resource: string,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { buildFormPayload } from "../crud/utils/buildFormPayload";
|
|
3
|
+
import { buildInlineRowsPayload } from "../crud/utils/buildInlineRowsPayload";
|
|
4
|
+
import { nestedFieldPath } from "../crud/utils/nestedFieldPath";
|
|
5
|
+
import { hasUploadValues } from "../crud/utils/hasUploadValues";
|
|
6
|
+
import { prepareFormSubmitBody } from "../crud/utils/prepareFormSubmitBody";
|
|
7
|
+
import { toFormData } from "../crud/utils/toFormData";
|
|
3
8
|
import { createRestResourceHandlers } from "./createRestResourceHandlers";
|
|
4
9
|
import {
|
|
5
10
|
toDjangoRestOrdering,
|
|
@@ -7,14 +12,14 @@ import {
|
|
|
7
12
|
toODataOrderBy,
|
|
8
13
|
} from "./sortHelpers";
|
|
9
14
|
|
|
10
|
-
describe("
|
|
15
|
+
describe("buildFormPayload", () => {
|
|
11
16
|
it("picks flat fields", () => {
|
|
12
17
|
const values = {
|
|
13
18
|
username: "jane",
|
|
14
19
|
email: "jane@example.com",
|
|
15
20
|
tenants: [{ id: 1 }],
|
|
16
21
|
};
|
|
17
|
-
expect(
|
|
22
|
+
expect(buildFormPayload(values, ["username", "email"])).toEqual({
|
|
18
23
|
username: "jane",
|
|
19
24
|
email: "jane@example.com",
|
|
20
25
|
});
|
|
@@ -25,14 +30,101 @@ describe("pickBySources", () => {
|
|
|
25
30
|
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
26
31
|
extra: "ignored",
|
|
27
32
|
};
|
|
28
|
-
expect(
|
|
33
|
+
expect(
|
|
34
|
+
buildFormPayload(values, ["invoiceLine.product", "invoiceLine.quantity"]),
|
|
35
|
+
).toEqual({
|
|
29
36
|
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
30
37
|
});
|
|
31
38
|
});
|
|
32
39
|
|
|
33
|
-
it("returns all values when no
|
|
40
|
+
it("returns all values when no field paths registered", () => {
|
|
34
41
|
const values = { a: 1, b: 2 };
|
|
35
|
-
expect(
|
|
42
|
+
expect(buildFormPayload(values, [])).toEqual(values);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("buildInlineRowsPayload", () => {
|
|
47
|
+
it("picks sources and keeps record id", () => {
|
|
48
|
+
const rows = [
|
|
49
|
+
{ rowKey: "a", id: 10, label: "A", quantity: 2, extra: "x" },
|
|
50
|
+
{ rowKey: "b", label: "B", quantity: 1 },
|
|
51
|
+
];
|
|
52
|
+
expect(buildInlineRowsPayload(rows, ["label", "quantity"])).toEqual([
|
|
53
|
+
{ label: "A", quantity: 2, id: 10 },
|
|
54
|
+
{ label: "B", quantity: 1 },
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("applies transformRows", () => {
|
|
59
|
+
const rows = [{ rowKey: "a", label: "A" }];
|
|
60
|
+
const result = buildInlineRowsPayload(rows, ["label"], {
|
|
61
|
+
transformRows: (cleaned) => cleaned.map((r) => ({ ...r, kind: "line" })),
|
|
62
|
+
});
|
|
63
|
+
expect(result).toEqual([{ label: "A", kind: "line" }]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("nestedFieldPath", () => {
|
|
68
|
+
it("builds dot paths", () => {
|
|
69
|
+
expect(nestedFieldPath("lines", 0, "label")).toBe("lines.0.label");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("hasUploadValues", () => {
|
|
74
|
+
it("detects File in nested payload", () => {
|
|
75
|
+
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
76
|
+
expect(hasUploadValues({ name: "x" })).toBe(false);
|
|
77
|
+
expect(hasUploadValues({ photo: file })).toBe(true);
|
|
78
|
+
expect(hasUploadValues({ lines: [{ photo: file }] })).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("toFormData", () => {
|
|
83
|
+
it("encodes flat scalars and files", () => {
|
|
84
|
+
const file = new File(["x"], "photo.png", { type: "image/png" });
|
|
85
|
+
const fd = toFormData({ name: "Widget", active: true, count: 2, photo: file });
|
|
86
|
+
|
|
87
|
+
expect(fd.get("name")).toBe("Widget");
|
|
88
|
+
expect(fd.get("active")).toBe("true");
|
|
89
|
+
expect(fd.get("count")).toBe("2");
|
|
90
|
+
expect(fd.get("photo")).toBe(file);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("encodes null as empty string and skips unchanged upload URLs", () => {
|
|
94
|
+
const fd = toFormData({
|
|
95
|
+
photo: null,
|
|
96
|
+
avatar: "https://cdn.example.com/a.jpg",
|
|
97
|
+
title: "Hello",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(fd.get("photo")).toBe("");
|
|
101
|
+
expect(fd.get("avatar")).toBeNull();
|
|
102
|
+
expect(fd.get("title")).toBe("Hello");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("encodes inline rows with bracket notation", () => {
|
|
106
|
+
const file = new File(["x"], "line.png", { type: "image/png" });
|
|
107
|
+
const fd = toFormData({
|
|
108
|
+
lines: [{ id: 10, label: "A", photo: file }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(fd.get("lines[0][id]")).toBe("10");
|
|
112
|
+
expect(fd.get("lines[0][label]")).toBe("A");
|
|
113
|
+
expect(fd.get("lines[0][photo]")).toBe(file);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("prepareFormSubmitBody", () => {
|
|
118
|
+
it("returns plain object when no uploads", () => {
|
|
119
|
+
const payload = { name: "Widget" };
|
|
120
|
+
expect(prepareFormSubmitBody(payload)).toBe(payload);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns FormData when uploads are present", () => {
|
|
124
|
+
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
125
|
+
const body = prepareFormSubmitBody({ name: "Widget", photo: file });
|
|
126
|
+
expect(body).toBeInstanceOf(FormData);
|
|
127
|
+
expect((body as FormData).get("photo")).toBe(file);
|
|
36
128
|
});
|
|
37
129
|
});
|
|
38
130
|
|
|
@@ -105,4 +197,24 @@ describe("createRestResourceHandlers", () => {
|
|
|
105
197
|
await handlers.update({ id: "1", data: { username: "bob" } });
|
|
106
198
|
expect(update).toHaveBeenCalledWith("1", { patch: { username: "bob" } });
|
|
107
199
|
});
|
|
200
|
+
|
|
201
|
+
it("passes FormData through without transform", async () => {
|
|
202
|
+
const create = vi.fn(async (data) => data);
|
|
203
|
+
const transformCreate = vi.fn((data) => data);
|
|
204
|
+
|
|
205
|
+
const handlers = createRestResourceHandlers({
|
|
206
|
+
list: async () => ({ data: [], total: 0 }),
|
|
207
|
+
retrieve: async () => ({}),
|
|
208
|
+
create,
|
|
209
|
+
update: async (_id, data) => data,
|
|
210
|
+
destroy: async () => {},
|
|
211
|
+
transformCreate,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const fd = new FormData();
|
|
215
|
+
fd.append("name", "Widget");
|
|
216
|
+
await handlers.create(fd);
|
|
217
|
+
expect(transformCreate).not.toHaveBeenCalled();
|
|
218
|
+
expect(create).toHaveBeenCalledWith(fd);
|
|
219
|
+
});
|
|
108
220
|
});
|