ding-react-admin 2.0.0 → 2.0.4

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.
Files changed (169) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +254 -195
  3. package/dist/index.css +2 -0
  4. package/dist/index.js +1772 -1169
  5. package/dist/src/components/NavMenuLabel.d.ts +8 -0
  6. package/dist/src/components/NavMenuLabel.d.ts.map +1 -0
  7. package/dist/src/components/NavMenuSearch.d.ts +9 -0
  8. package/dist/src/components/NavMenuSearch.d.ts.map +1 -0
  9. package/dist/src/components/ScrollableArea.d.ts +10 -0
  10. package/dist/src/components/ScrollableArea.d.ts.map +1 -0
  11. package/dist/src/components/index.d.ts +3 -0
  12. package/dist/src/components/index.d.ts.map +1 -1
  13. package/dist/src/context/AuthProvider.d.ts +2 -0
  14. package/dist/src/context/AuthProvider.d.ts.map +1 -1
  15. package/dist/src/crud/FormGlobalErrorsAlert.d.ts +5 -0
  16. package/dist/src/crud/FormGlobalErrorsAlert.d.ts.map +1 -0
  17. package/dist/src/crud/ResourceForm.d.ts +1 -4
  18. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  19. package/dist/src/crud/ResourceFormModal.d.ts +7 -1
  20. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  21. package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
  22. package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
  23. package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
  24. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  25. package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
  26. package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
  27. package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
  28. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  29. package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
  30. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
  31. package/dist/src/crud/index.d.ts +5 -0
  32. package/dist/src/crud/index.d.ts.map +1 -1
  33. package/dist/src/crud/types.d.ts +12 -0
  34. package/dist/src/crud/types.d.ts.map +1 -1
  35. package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
  36. package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
  37. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
  38. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
  39. package/dist/src/crud/utils/formErrors.d.ts +18 -7
  40. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  41. package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
  42. package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
  43. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +1 -0
  44. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -1
  45. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
  46. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
  47. package/dist/src/crud/utils/toFormData.d.ts +1 -1
  48. package/dist/src/crud/utils/toFormData.d.ts.map +1 -1
  49. package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
  50. package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
  51. package/dist/src/crud/utils/useChoices.d.ts +27 -1
  52. package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
  53. package/dist/src/crud/utils/useFormRecord.d.ts +9 -7
  54. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -1
  55. package/dist/src/data/parseFormErrorHelpers.d.ts +6 -27
  56. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  57. package/dist/src/index.d.ts +2 -2
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/dist/src/layouts/AdminLayout.d.ts +1 -1
  60. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  61. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
  62. package/dist/src/layouts/navFilter.d.ts +5 -0
  63. package/dist/src/layouts/navFilter.d.ts.map +1 -0
  64. package/dist/src/layouts/navMenuItems.d.ts +6 -0
  65. package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
  66. package/dist/src/types.d.ts +12 -0
  67. package/dist/src/types.d.ts.map +1 -1
  68. package/package.json +79 -59
  69. package/src/app/AdminApp.tsx +34 -34
  70. package/src/components/AuthAlternateLink.tsx +23 -23
  71. package/src/components/DensitySwitch.tsx +31 -31
  72. package/src/components/NavMenuLabel.tsx +26 -0
  73. package/src/components/NavMenuSearch.tsx +76 -0
  74. package/src/components/ScrollableArea.tsx +46 -0
  75. package/src/components/ThemeSwitch.tsx +28 -28
  76. package/src/components/ThemeToolbar.tsx +34 -34
  77. package/src/components/index.ts +7 -4
  78. package/src/components/navMenu.css +21 -0
  79. package/src/components/scrollable.css +28 -0
  80. package/src/context/AppThemeProvider.tsx +128 -128
  81. package/src/context/AuthProvider.tsx +89 -77
  82. package/src/context/DataProvider.tsx +30 -30
  83. package/src/context/PermissionsProvider.tsx +57 -57
  84. package/src/crud/FilterBar.tsx +36 -36
  85. package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
  86. package/src/crud/FormSteps.tsx +135 -135
  87. package/src/crud/FormTabs.tsx +126 -126
  88. package/src/crud/InlineFormSet.tsx +162 -162
  89. package/src/crud/ListActionsBar.tsx +106 -106
  90. package/src/crud/ResourceForm.tsx +83 -160
  91. package/src/crud/ResourceFormModal.tsx +75 -133
  92. package/src/crud/ResourceRecordForm.tsx +166 -0
  93. package/src/crud/columns/BooleanColumn.tsx +32 -32
  94. package/src/crud/columns/CustomColumn.tsx +33 -33
  95. package/src/crud/columns/DateColumn.tsx +28 -28
  96. package/src/crud/columns/ImageColumn.tsx +58 -58
  97. package/src/crud/columns/NumberColumn.tsx +31 -31
  98. package/src/crud/columns/ReferenceColumn.tsx +85 -85
  99. package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
  100. package/src/crud/columns/TextColumn.tsx +38 -38
  101. package/src/crud/context/FilterContext.tsx +71 -71
  102. package/src/crud/context/FormContext.tsx +39 -39
  103. package/src/crud/context/FormSectionContext.tsx +22 -22
  104. package/src/crud/context/InlineFieldsRegistry.tsx +61 -61
  105. package/src/crud/context/ListContext.tsx +93 -93
  106. package/src/crud/fields/BooleanField.tsx +38 -38
  107. package/src/crud/fields/DateField.tsx +53 -53
  108. package/src/crud/fields/FileField.tsx +123 -123
  109. package/src/crud/fields/ImageField.tsx +120 -120
  110. package/src/crud/fields/NumberField.tsx +52 -52
  111. package/src/crud/fields/PasswordField.tsx +99 -99
  112. package/src/crud/fields/ReferenceField.tsx +237 -96
  113. package/src/crud/fields/ReferenceInputActions.tsx +106 -0
  114. package/src/crud/fields/ReferenceManyField.tsx +200 -76
  115. package/src/crud/fields/SelectField.tsx +48 -48
  116. package/src/crud/fields/TextField.tsx +46 -46
  117. package/src/crud/fields/uploadFieldUtils.ts +21 -21
  118. package/src/crud/fields/useUploadPreviewUrl.ts +20 -20
  119. package/src/crud/filters/BooleanFilter.tsx +38 -38
  120. package/src/crud/filters/DateFilter.tsx +35 -35
  121. package/src/crud/filters/NumberFilter.tsx +33 -33
  122. package/src/crud/filters/ReferenceFilter.tsx +192 -173
  123. package/src/crud/filters/SelectFilter.tsx +44 -44
  124. package/src/crud/filters/TextFilter.tsx +36 -36
  125. package/src/crud/index.ts +99 -94
  126. package/src/crud/types.ts +146 -134
  127. package/src/crud/utils/buildFormPayload.ts +24 -24
  128. package/src/crud/utils/buildInlineRowsPayload.ts +37 -37
  129. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -29
  130. package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
  131. package/src/crud/utils/choiceSelectionUtils.ts +116 -0
  132. package/src/crud/utils/formErrors.test.ts +168 -0
  133. package/src/crud/utils/formErrors.ts +138 -42
  134. package/src/crud/utils/formSectionErrors.ts +85 -85
  135. package/src/crud/utils/getFormValue.ts +13 -13
  136. package/src/crud/utils/hasUploadValues.ts +9 -9
  137. package/src/crud/utils/nestedFieldPath.ts +8 -8
  138. package/src/crud/utils/prepareFormSubmitBody.ts +22 -17
  139. package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
  140. package/src/crud/utils/setFormValue.ts +18 -18
  141. package/src/crud/utils/sortQueryParam.ts +37 -37
  142. package/src/crud/utils/toFormData.ts +79 -81
  143. package/src/crud/utils/uploadReferenceUtils.ts +44 -0
  144. package/src/crud/utils/useAbortableEffect.ts +17 -17
  145. package/src/crud/utils/useChoices.ts +330 -182
  146. package/src/crud/utils/useFormRecord.ts +179 -156
  147. package/src/crud/utils/useListQueryState.ts +191 -191
  148. package/src/data/abortError.test.ts +19 -19
  149. package/src/data/abortError.ts +10 -10
  150. package/src/data/createMemoryResourceHandlers.ts +78 -78
  151. package/src/data/createRestResourceHandlers.ts +63 -63
  152. package/src/data/dataUtils.test.ts +228 -220
  153. package/src/data/inMemoryList.ts +97 -97
  154. package/src/data/parseFormErrorHelpers.test.ts +201 -138
  155. package/src/data/parseFormErrorHelpers.ts +376 -311
  156. package/src/data/sortHelpers.ts +48 -48
  157. package/src/index.ts +211 -207
  158. package/src/layouts/AdminLayout.tsx +530 -382
  159. package/src/layouts/AuthPageLayout.tsx +87 -81
  160. package/src/layouts/navFilter.ts +49 -0
  161. package/src/layouts/navMenuItems.tsx +44 -0
  162. package/src/pages/LoginPage.tsx +80 -80
  163. package/src/pages/PlaceholderPage.tsx +9 -9
  164. package/src/permissions/resourcePermissions.ts +47 -47
  165. package/src/router/createAdminRouter.tsx +64 -64
  166. package/src/router/guards.tsx +63 -63
  167. package/src/router/routeAccess.ts +89 -89
  168. package/src/types.ts +10 -0
  169. 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)).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);
128
- });
129
- });
130
-
131
- describe("sort helpers", () => {
132
- it("toDjangoRestOrdering", () => {
133
- expect(toDjangoRestOrdering({ field: "username", order: "ASC" })).toBe(
134
- "username",
135
- );
136
- expect(toDjangoRestOrdering({ field: "username", order: "DESC" })).toBe(
137
- "-username",
138
- );
139
- expect(
140
- toDjangoRestOrdering([
141
- { field: "username", order: "ASC" },
142
- { field: "date_joined", order: "DESC" },
143
- ]),
144
- ).toBe("username,-date_joined");
145
- });
146
-
147
- it("toODataOrderBy", () => {
148
- expect(toODataOrderBy({ field: "username", order: "DESC" })).toBe(
149
- "username desc",
150
- );
151
- });
152
-
153
- it("toJsonApiSort", () => {
154
- expect(toJsonApiSort({ field: "username", order: "DESC" })).toBe(
155
- "-username",
156
- );
157
- });
158
- });
159
-
160
- describe("createRestResourceHandlers", () => {
161
- it("passes form payload through on create/update", async () => {
162
- const create = vi.fn(async (data) => ({ id: "1", ...data }));
163
- const update = vi.fn(async (_id, data) => ({ id: "1", ...data }));
164
-
165
- const handlers = createRestResourceHandlers({
166
- list: async () => ({ data: [], total: 0 }),
167
- retrieve: async () => ({ id: "1" }),
168
- create,
169
- update,
170
- destroy: async () => {},
171
- });
172
-
173
- await handlers.create({ username: "jane" });
174
- expect(create).toHaveBeenCalledWith({ username: "jane" });
175
-
176
- await handlers.update({ id: "1", data: { email: "a@b.c" } });
177
- expect(update).toHaveBeenCalledWith("1", { email: "a@b.c" });
178
- });
179
-
180
- it("applies transformCreate and transformUpdate when provided", async () => {
181
- const create = vi.fn(async (data) => data);
182
- const update = vi.fn(async (_id, data) => data);
183
-
184
- const handlers = createRestResourceHandlers({
185
- list: async () => ({ data: [], total: 0 }),
186
- retrieve: async () => ({}),
187
- create,
188
- update,
189
- destroy: async () => {},
190
- transformCreate: (data) => ({ user: data }),
191
- transformUpdate: (data) => ({ patch: data }),
192
- });
193
-
194
- await handlers.create({ username: "jane" });
195
- expect(create).toHaveBeenCalledWith({ user: { username: "jane" } });
196
-
197
- await handlers.update({ id: "1", data: { username: "bob" } });
198
- expect(update).toHaveBeenCalledWith("1", { patch: { username: "bob" } });
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
- });
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
+ });