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,63 +1,63 @@
|
|
|
1
|
-
import type { GetListParams, GetOneParams, Identifier, FormMutationBody } from "./dataProviderTypes";
|
|
2
|
-
import type { ResourceHandlers } from "./resourceHandlers";
|
|
3
|
-
|
|
4
|
-
export type RestResourceHandlersConfig<
|
|
5
|
-
T extends Record<string, unknown> = Record<string, unknown>,
|
|
6
|
-
> = {
|
|
7
|
-
list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
|
|
8
|
-
retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
|
|
9
|
-
create: (data: FormMutationBody) => Promise<T>;
|
|
10
|
-
update: (id: Identifier, data: FormMutationBody) => Promise<T>;
|
|
11
|
-
destroy: (id: Identifier) => Promise<void>;
|
|
12
|
-
/** When API create body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
13
|
-
transformCreate?: (data: Record<string, unknown>) => unknown;
|
|
14
|
-
/** When API update body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
15
|
-
transformUpdate?: (data: Record<string, unknown>) => unknown;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Thin CRUD glue for REST backends. Form payload comes from field `source` paths;
|
|
20
|
-
* wire your fetch or API client in the five functions below.
|
|
21
|
-
*/
|
|
22
|
-
export function createRestResourceHandlers<
|
|
23
|
-
T extends Record<string, unknown> = Record<string, unknown>,
|
|
24
|
-
>(config: RestResourceHandlersConfig<T>): ResourceHandlers<T> {
|
|
25
|
-
return {
|
|
26
|
-
async getList(params) {
|
|
27
|
-
return config.list(params);
|
|
28
|
-
},
|
|
29
|
-
|
|
30
|
-
async getOne(id, params) {
|
|
31
|
-
return { data: await config.retrieve(id, params) };
|
|
32
|
-
},
|
|
33
|
-
|
|
34
|
-
async create(data) {
|
|
35
|
-
const body =
|
|
36
|
-
data instanceof FormData
|
|
37
|
-
? data
|
|
38
|
-
: config.transformCreate
|
|
39
|
-
? config.transformCreate(data as Record<string, unknown>)
|
|
40
|
-
: data;
|
|
41
|
-
return {
|
|
42
|
-
data: await config.create(body as FormMutationBody),
|
|
43
|
-
};
|
|
44
|
-
},
|
|
45
|
-
|
|
46
|
-
async update({ id, data }) {
|
|
47
|
-
const body =
|
|
48
|
-
data instanceof FormData
|
|
49
|
-
? data
|
|
50
|
-
: config.transformUpdate
|
|
51
|
-
? config.transformUpdate(data as Record<string, unknown>)
|
|
52
|
-
: data;
|
|
53
|
-
return {
|
|
54
|
-
data: await config.update(id, body as FormMutationBody),
|
|
55
|
-
};
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
async delete(id) {
|
|
59
|
-
await config.destroy(id);
|
|
60
|
-
return { data: null };
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
|
1
|
+
import type { GetListParams, GetOneParams, Identifier, FormMutationBody } from "./dataProviderTypes";
|
|
2
|
+
import type { ResourceHandlers } from "./resourceHandlers";
|
|
3
|
+
|
|
4
|
+
export type RestResourceHandlersConfig<
|
|
5
|
+
T extends Record<string, unknown> = Record<string, unknown>,
|
|
6
|
+
> = {
|
|
7
|
+
list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
|
|
8
|
+
retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
|
|
9
|
+
create: (data: FormMutationBody) => Promise<T>;
|
|
10
|
+
update: (id: Identifier, data: FormMutationBody) => Promise<T>;
|
|
11
|
+
destroy: (id: Identifier) => Promise<void>;
|
|
12
|
+
/** When API create body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
13
|
+
transformCreate?: (data: Record<string, unknown>) => unknown;
|
|
14
|
+
/** When API update body differs from form `source` paths. Not applied when body is already `FormData`. */
|
|
15
|
+
transformUpdate?: (data: Record<string, unknown>) => unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Thin CRUD glue for REST backends. Form payload comes from field `source` paths;
|
|
20
|
+
* wire your fetch or API client in the five functions below.
|
|
21
|
+
*/
|
|
22
|
+
export function createRestResourceHandlers<
|
|
23
|
+
T extends Record<string, unknown> = Record<string, unknown>,
|
|
24
|
+
>(config: RestResourceHandlersConfig<T>): ResourceHandlers<T> {
|
|
25
|
+
return {
|
|
26
|
+
async getList(params) {
|
|
27
|
+
return config.list(params);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async getOne(id, params) {
|
|
31
|
+
return { data: await config.retrieve(id, params) };
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
async create(data) {
|
|
35
|
+
const body =
|
|
36
|
+
data instanceof FormData
|
|
37
|
+
? data
|
|
38
|
+
: config.transformCreate
|
|
39
|
+
? config.transformCreate(data as Record<string, unknown>)
|
|
40
|
+
: data;
|
|
41
|
+
return {
|
|
42
|
+
data: await config.create(body as FormMutationBody),
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async update({ id, data }) {
|
|
47
|
+
const body =
|
|
48
|
+
data instanceof FormData
|
|
49
|
+
? data
|
|
50
|
+
: config.transformUpdate
|
|
51
|
+
? config.transformUpdate(data as Record<string, unknown>)
|
|
52
|
+
: data;
|
|
53
|
+
return {
|
|
54
|
+
data: await config.update(id, body as FormMutationBody),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async delete(id) {
|
|
59
|
+
await config.destroy(id);
|
|
60
|
+
return { data: null };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -1,220 +1,228 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
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";
|
|
8
|
-
import { createRestResourceHandlers } from "./createRestResourceHandlers";
|
|
9
|
-
import {
|
|
10
|
-
toDjangoRestOrdering,
|
|
11
|
-
toJsonApiSort,
|
|
12
|
-
toODataOrderBy,
|
|
13
|
-
} from "./sortHelpers";
|
|
14
|
-
|
|
15
|
-
describe("buildFormPayload", () => {
|
|
16
|
-
it("picks flat fields", () => {
|
|
17
|
-
const values = {
|
|
18
|
-
username: "jane",
|
|
19
|
-
email: "jane@example.com",
|
|
20
|
-
tenants: [{ id: 1 }],
|
|
21
|
-
};
|
|
22
|
-
expect(buildFormPayload(values, ["username", "email"])).toEqual({
|
|
23
|
-
username: "jane",
|
|
24
|
-
email: "jane@example.com",
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("picks nested fields", () => {
|
|
29
|
-
const values = {
|
|
30
|
-
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
31
|
-
extra: "ignored",
|
|
32
|
-
};
|
|
33
|
-
expect(
|
|
34
|
-
buildFormPayload(values, ["invoiceLine.product", "invoiceLine.quantity"]),
|
|
35
|
-
).toEqual({
|
|
36
|
-
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("returns all values when no field paths registered", () => {
|
|
41
|
-
const values = { a: 1, b: 2 };
|
|
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)).
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it("
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
it("
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
});
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
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";
|
|
8
|
+
import { createRestResourceHandlers } from "./createRestResourceHandlers";
|
|
9
|
+
import {
|
|
10
|
+
toDjangoRestOrdering,
|
|
11
|
+
toJsonApiSort,
|
|
12
|
+
toODataOrderBy,
|
|
13
|
+
} from "./sortHelpers";
|
|
14
|
+
|
|
15
|
+
describe("buildFormPayload", () => {
|
|
16
|
+
it("picks flat fields", () => {
|
|
17
|
+
const values = {
|
|
18
|
+
username: "jane",
|
|
19
|
+
email: "jane@example.com",
|
|
20
|
+
tenants: [{ id: 1 }],
|
|
21
|
+
};
|
|
22
|
+
expect(buildFormPayload(values, ["username", "email"])).toEqual({
|
|
23
|
+
username: "jane",
|
|
24
|
+
email: "jane@example.com",
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("picks nested fields", () => {
|
|
29
|
+
const values = {
|
|
30
|
+
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
31
|
+
extra: "ignored",
|
|
32
|
+
};
|
|
33
|
+
expect(
|
|
34
|
+
buildFormPayload(values, ["invoiceLine.product", "invoiceLine.quantity"]),
|
|
35
|
+
).toEqual({
|
|
36
|
+
invoiceLine: { product: "SKU-1", quantity: 2 },
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns all values when no field paths registered", () => {
|
|
41
|
+
const values = { a: 1, b: 2 };
|
|
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)).toEqual(payload);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("omits unchanged upload references from JSON payloads", () => {
|
|
124
|
+
const body = prepareFormSubmitBody({
|
|
125
|
+
name: "Widget",
|
|
126
|
+
photo: "/media/employee_pictures/default.png",
|
|
127
|
+
});
|
|
128
|
+
expect(body).toEqual({ name: "Widget" });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns FormData when uploads are present", () => {
|
|
132
|
+
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
133
|
+
const body = prepareFormSubmitBody({ name: "Widget", photo: file });
|
|
134
|
+
expect(body).toBeInstanceOf(FormData);
|
|
135
|
+
expect((body as FormData).get("photo")).toBe(file);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("sort helpers", () => {
|
|
140
|
+
it("toDjangoRestOrdering", () => {
|
|
141
|
+
expect(toDjangoRestOrdering({ field: "username", order: "ASC" })).toBe(
|
|
142
|
+
"username",
|
|
143
|
+
);
|
|
144
|
+
expect(toDjangoRestOrdering({ field: "username", order: "DESC" })).toBe(
|
|
145
|
+
"-username",
|
|
146
|
+
);
|
|
147
|
+
expect(
|
|
148
|
+
toDjangoRestOrdering([
|
|
149
|
+
{ field: "username", order: "ASC" },
|
|
150
|
+
{ field: "date_joined", order: "DESC" },
|
|
151
|
+
]),
|
|
152
|
+
).toBe("username,-date_joined");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("toODataOrderBy", () => {
|
|
156
|
+
expect(toODataOrderBy({ field: "username", order: "DESC" })).toBe(
|
|
157
|
+
"username desc",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("toJsonApiSort", () => {
|
|
162
|
+
expect(toJsonApiSort({ field: "username", order: "DESC" })).toBe(
|
|
163
|
+
"-username",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("createRestResourceHandlers", () => {
|
|
169
|
+
it("passes form payload through on create/update", async () => {
|
|
170
|
+
const create = vi.fn(async (data) => ({ id: "1", ...data }));
|
|
171
|
+
const update = vi.fn(async (_id, data) => ({ id: "1", ...data }));
|
|
172
|
+
|
|
173
|
+
const handlers = createRestResourceHandlers({
|
|
174
|
+
list: async () => ({ data: [], total: 0 }),
|
|
175
|
+
retrieve: async () => ({ id: "1" }),
|
|
176
|
+
create,
|
|
177
|
+
update,
|
|
178
|
+
destroy: async () => {},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await handlers.create({ username: "jane" });
|
|
182
|
+
expect(create).toHaveBeenCalledWith({ username: "jane" });
|
|
183
|
+
|
|
184
|
+
await handlers.update({ id: "1", data: { email: "a@b.c" } });
|
|
185
|
+
expect(update).toHaveBeenCalledWith("1", { email: "a@b.c" });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("applies transformCreate and transformUpdate when provided", async () => {
|
|
189
|
+
const create = vi.fn(async (data) => data);
|
|
190
|
+
const update = vi.fn(async (_id, data) => data);
|
|
191
|
+
|
|
192
|
+
const handlers = createRestResourceHandlers({
|
|
193
|
+
list: async () => ({ data: [], total: 0 }),
|
|
194
|
+
retrieve: async () => ({}),
|
|
195
|
+
create,
|
|
196
|
+
update,
|
|
197
|
+
destroy: async () => {},
|
|
198
|
+
transformCreate: (data) => ({ user: data }),
|
|
199
|
+
transformUpdate: (data) => ({ patch: data }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await handlers.create({ username: "jane" });
|
|
203
|
+
expect(create).toHaveBeenCalledWith({ user: { username: "jane" } });
|
|
204
|
+
|
|
205
|
+
await handlers.update({ id: "1", data: { username: "bob" } });
|
|
206
|
+
expect(update).toHaveBeenCalledWith("1", { patch: { username: "bob" } });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("passes FormData through without transform", async () => {
|
|
210
|
+
const create = vi.fn(async (data) => data);
|
|
211
|
+
const transformCreate = vi.fn((data) => data);
|
|
212
|
+
|
|
213
|
+
const handlers = createRestResourceHandlers({
|
|
214
|
+
list: async () => ({ data: [], total: 0 }),
|
|
215
|
+
retrieve: async () => ({}),
|
|
216
|
+
create,
|
|
217
|
+
update: async (_id, data) => data,
|
|
218
|
+
destroy: async () => {},
|
|
219
|
+
transformCreate,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const fd = new FormData();
|
|
223
|
+
fd.append("name", "Widget");
|
|
224
|
+
await handlers.create(fd);
|
|
225
|
+
expect(transformCreate).not.toHaveBeenCalled();
|
|
226
|
+
expect(create).toHaveBeenCalledWith(fd);
|
|
227
|
+
});
|
|
228
|
+
});
|