ding-react-admin 1.0.4 → 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.
Files changed (112) hide show
  1. package/README.md +4 -10
  2. package/dist/index.js +1071 -803
  3. package/dist/src/crud/InlineFormSet.d.ts +2 -17
  4. package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
  5. package/dist/src/crud/ResourceForm.d.ts +4 -9
  6. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  7. package/dist/src/crud/ResourceList.d.ts.map +1 -1
  8. package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
  9. package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
  10. package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
  11. package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
  12. package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
  13. package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
  14. package/dist/src/crud/fields/FileField.d.ts +12 -0
  15. package/dist/src/crud/fields/FileField.d.ts.map +1 -0
  16. package/dist/src/crud/fields/ImageField.d.ts +14 -0
  17. package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
  18. package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
  19. package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
  20. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
  21. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
  22. package/dist/src/crud/index.d.ts +22 -9
  23. package/dist/src/crud/index.d.ts.map +1 -1
  24. package/dist/src/crud/types.d.ts +9 -7
  25. package/dist/src/crud/types.d.ts.map +1 -1
  26. package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
  27. package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
  28. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
  29. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
  30. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
  31. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
  32. package/dist/src/crud/utils/formErrors.d.ts +1 -1
  33. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  34. package/dist/src/crud/utils/getFormValue.d.ts +3 -0
  35. package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
  36. package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
  37. package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
  38. package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
  39. package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
  40. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +7 -0
  41. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
  42. package/dist/src/crud/utils/setFormValue.d.ts +3 -0
  43. package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
  44. package/dist/src/crud/utils/toFormData.d.ts +13 -0
  45. package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
  46. package/dist/src/crud/utils/useFormRecord.d.ts +42 -0
  47. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
  48. package/dist/src/data/createRestResourceHandlers.d.ts +5 -5
  49. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
  50. package/dist/src/data/dataProviderTypes.d.ts +6 -5
  51. package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
  52. package/dist/src/data/parseFormErrorHelpers.d.ts +44 -6
  53. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  54. package/dist/src/data/resourceHandlers.d.ts +1 -1
  55. package/dist/src/data/resourceHandlers.d.ts.map +1 -1
  56. package/dist/src/index.d.ts +4 -4
  57. package/dist/src/index.d.ts.map +1 -1
  58. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  59. package/package.json +1 -1
  60. package/src/crud/InlineFormSet.tsx +28 -85
  61. package/src/crud/ResourceForm.tsx +60 -66
  62. package/src/crud/ResourceFormModal.tsx +25 -25
  63. package/src/crud/ResourceList.tsx +1 -0
  64. package/src/crud/columns/ImageColumn.tsx +58 -0
  65. package/src/crud/columns/TextColumn.tsx +2 -2
  66. package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
  67. package/src/crud/context/PayloadFieldsContext.tsx +62 -0
  68. package/src/crud/fields/FieldWrapper.tsx +87 -87
  69. package/src/crud/fields/FileField.tsx +123 -0
  70. package/src/crud/fields/ImageField.tsx +120 -0
  71. package/src/crud/fields/uploadFieldUtils.ts +21 -0
  72. package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
  73. package/src/crud/index.ts +23 -19
  74. package/src/crud/types.ts +9 -7
  75. package/src/crud/utils/buildFormPayload.ts +24 -0
  76. package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
  77. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
  78. package/src/crud/utils/formErrors.ts +42 -38
  79. package/src/crud/utils/{getByPath.ts → getFormValue.ts} +2 -2
  80. package/src/crud/utils/hasUploadValues.ts +9 -0
  81. package/src/crud/utils/nestedFieldPath.ts +8 -0
  82. package/src/crud/utils/prepareFormSubmitBody.ts +17 -0
  83. package/src/crud/utils/{setByPath.ts → setFormValue.ts} +2 -2
  84. package/src/crud/utils/toFormData.ts +81 -0
  85. package/src/crud/utils/useFormRecord.ts +156 -0
  86. package/src/data/createRestResourceHandlers.ts +19 -13
  87. package/src/data/dataProviderTypes.ts +7 -5
  88. package/src/data/dataUtils.test.ts +118 -6
  89. package/src/data/parseFormErrorHelpers.test.ts +138 -84
  90. package/src/data/parseFormErrorHelpers.ts +311 -224
  91. package/src/data/resourceHandlers.ts +1 -1
  92. package/src/index.ts +23 -11
  93. package/src/layouts/AdminLayout.tsx +2 -1
  94. package/dist/src/crud/context/SubmitFieldsContext.d.ts +0 -18
  95. package/dist/src/crud/context/SubmitFieldsContext.d.ts.map +0 -1
  96. package/dist/src/crud/utils/getByPath.d.ts +0 -3
  97. package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
  98. package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
  99. package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
  100. package/dist/src/crud/utils/inlineFieldName.d.ts +0 -3
  101. package/dist/src/crud/utils/inlineFieldName.d.ts.map +0 -1
  102. package/dist/src/crud/utils/pickBySources.d.ts +0 -6
  103. package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
  104. package/dist/src/crud/utils/setByPath.d.ts +0 -3
  105. package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
  106. package/dist/src/crud/utils/useResourceFormData.d.ts +0 -47
  107. package/dist/src/crud/utils/useResourceFormData.d.ts.map +0 -1
  108. package/src/crud/context/SubmitFieldsContext.tsx +0 -62
  109. package/src/crud/utils/inlineArrayName.ts +0 -4
  110. package/src/crud/utils/inlineFieldName.ts +0 -8
  111. package/src/crud/utils/pickBySources.ts +0 -24
  112. package/src/crud/utils/useResourceFormData.ts +0 -231
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import type { GetListParams, GetOneParams, 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<
@@ -6,12 +6,12 @@ export type RestResourceHandlersConfig<
6
6
  > = {
7
7
  list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
8
8
  retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
9
- create: (data: Record<string, unknown>) => Promise<T>;
10
- update: (id: Identifier, data: Record<string, unknown>) => 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
 
@@ -32,20 +32,26 @@ export function createRestResourceHandlers<
32
32
  },
33
33
 
34
34
  async create(data) {
35
- const body = config.transformCreate
36
- ? config.transformCreate(data as Record<string, unknown>)
37
- : data;
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 Record<string, unknown>),
42
+ data: await config.create(body as FormMutationBody),
40
43
  };
41
44
  },
42
45
 
43
46
  async update({ id, data }) {
44
- const body = config.transformUpdate
45
- ? config.transformUpdate(data as Record<string, unknown>)
46
- : data;
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 Record<string, unknown>),
54
+ data: await config.update(id, body as FormMutationBody),
49
55
  };
50
56
  },
51
57
 
@@ -43,7 +43,7 @@ export type CreateResult<RecordType extends Record<string, unknown>> = {
43
43
 
44
44
  export type UpdateParams<RecordType extends Record<string, unknown>> = {
45
45
  id: Identifier;
46
- data: Partial<RecordType>;
46
+ data: Partial<RecordType> | FormData;
47
47
  };
48
48
 
49
49
  export type UpdateResult<RecordType extends Record<string, unknown>> = {
@@ -54,14 +54,16 @@ export type DeleteResult<RecordType extends Record<string, unknown>> = {
54
54
  data: RecordType | null;
55
55
  };
56
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
+
57
60
  export type FormMutation = "create" | "update";
58
61
 
59
62
  export type ParseFormErrorContext = {
60
63
  resource: string;
61
64
  mutation: FormMutation;
62
- /** Set when saving an inline row — prefix field paths for that row. */
63
- inlineArrayName?: string;
64
- rowIndex?: number;
65
+ /** Top-level inline field-array paths on the form, e.g. `["lines"]`. */
66
+ inlineFieldPaths?: string[];
65
67
  };
66
68
 
67
69
  /** Field paths match react-hook-form `name` (form `source` or inline `arrayName.index.source`). */
@@ -94,7 +96,7 @@ export type DataProvider<
94
96
  ) => Promise<GetOneResult<RecordType>>;
95
97
  create: (
96
98
  resource: string,
97
- data: Partial<RecordType>,
99
+ data: Partial<RecordType> | FormData,
98
100
  ) => Promise<CreateResult<RecordType>>;
99
101
  update: (
100
102
  resource: string,
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
- import { pickBySources } from "../crud/utils/pickBySources";
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("pickBySources", () => {
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(pickBySources(values, ["username", "email"])).toEqual({
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(pickBySources(values, ["invoiceLine.product", "invoiceLine.quantity"])).toEqual({
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 sources registered", () => {
40
+ it("returns all values when no field paths registered", () => {
34
41
  const values = { a: 1, b: 2 };
35
- expect(pickBySources(values, [])).toEqual(values);
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
  });
@@ -1,84 +1,138 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- parseDjangoDRFFormErrors,
4
- parseDotNetFormErrors,
5
- parseNodeFormErrors,
6
- } from "./parseFormErrorHelpers";
7
-
8
- const mainCtx = { resource: "users", mutation: "create" as const };
9
- const inlineCtx = {
10
- resource: "invoice-lines",
11
- mutation: "create" as const,
12
- inlineArrayName: "__inline_invoice_lines",
13
- rowIndex: 1,
14
- };
15
-
16
- describe("parseDjangoDRFFormErrors", () => {
17
- it("maps field and non_field_errors", () => {
18
- const parsed = parseDjangoDRFFormErrors(
19
- {
20
- body: {
21
- email: ["Enter a valid email."],
22
- non_field_errors: ["Cannot save"],
23
- },
24
- },
25
- mainCtx,
26
- );
27
- expect(parsed).toEqual({
28
- fields: { email: "Enter a valid email." },
29
- global: ["Cannot save"],
30
- });
31
- });
32
-
33
- it("prefixes inline row fields", () => {
34
- const parsed = parseDjangoDRFFormErrors(
35
- { body: { quantity: ["Must be positive"] } },
36
- inlineCtx,
37
- );
38
- expect(parsed?.fields).toEqual({
39
- "__inline_invoice_lines.1.quantity": "Must be positive",
40
- });
41
- });
42
- });
43
-
44
- describe("parseDotNetFormErrors", () => {
45
- it("maps errors object with camelCase", () => {
46
- const parsed = parseDotNetFormErrors(
47
- {
48
- body: {
49
- errors: { Email: ["The Email field is required."] },
50
- },
51
- },
52
- mainCtx,
53
- );
54
- expect(parsed?.fields).toEqual({
55
- email: "The Email field is required.",
56
- });
57
- });
58
- });
59
-
60
- describe("parseNodeFormErrors", () => {
61
- it("maps express-validator array", () => {
62
- const parsed = parseNodeFormErrors(
63
- {
64
- body: {
65
- errors: [{ path: "email", msg: "Invalid email" }],
66
- },
67
- },
68
- mainCtx,
69
- );
70
- expect(parsed?.fields).toEqual({ email: "Invalid email" });
71
- });
72
-
73
- it("maps Joi details", () => {
74
- const parsed = parseNodeFormErrors(
75
- {
76
- body: {
77
- details: [{ message: "Required", path: ["email"] }],
78
- },
79
- },
80
- mainCtx,
81
- );
82
- expect(parsed?.fields).toEqual({ email: "Required" });
83
- });
84
- });
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parseDjangoDRFFormErrors,
4
+ parseDotNetFormErrors,
5
+ parseNodeFormErrors,
6
+ flattenNestedArrayErrors,
7
+ resolveErrorBody,
8
+ } from "./parseFormErrorHelpers";
9
+
10
+ const mainCtx = { resource: "users", mutation: "create" as const };
11
+
12
+ describe("parseDjangoDRFFormErrors", () => {
13
+ it("maps field and non_field_errors", () => {
14
+ const parsed = parseDjangoDRFFormErrors(
15
+ {
16
+ body: {
17
+ email: ["Enter a valid email."],
18
+ non_field_errors: ["Cannot save"],
19
+ },
20
+ },
21
+ mainCtx,
22
+ );
23
+ expect(parsed).toEqual({
24
+ fields: { email: "Enter a valid email." },
25
+ global: ["Cannot save"],
26
+ });
27
+ });
28
+
29
+ it("flattens nested inline row errors", () => {
30
+ const parsed = parseDjangoDRFFormErrors(
31
+ {
32
+ body: {
33
+ lines: [
34
+ { quantity: ["Must be positive"] },
35
+ {},
36
+ { label: ["Required"] },
37
+ ],
38
+ },
39
+ },
40
+ mainCtx,
41
+ );
42
+ expect(parsed?.fields).toEqual({
43
+ "lines.0.quantity": "Must be positive",
44
+ "lines.2.label": "Required",
45
+ });
46
+ });
47
+ });
48
+
49
+ describe("resolveErrorBody", () => {
50
+ it("reads axios-style response.data", async () => {
51
+ const body = await resolveErrorBody({
52
+ response: { data: { email: ["Invalid"] } },
53
+ });
54
+ expect(body).toEqual({ email: ["Invalid"] });
55
+ });
56
+
57
+ it("reads fetch ResponseError response.json()", async () => {
58
+ const response = new Response(
59
+ JSON.stringify({ email: ["Enter a valid email address."] }),
60
+ {
61
+ status: 400,
62
+ headers: { "Content-Type": "application/json" },
63
+ },
64
+ );
65
+ const body = await resolveErrorBody({
66
+ name: "ResponseError",
67
+ message: "Response returned an error code",
68
+ response,
69
+ });
70
+ expect(body).toEqual({ email: ["Enter a valid email address."] });
71
+ });
72
+
73
+ it("returns null for non-json fetch responses", async () => {
74
+ const response = new Response("bad request", {
75
+ status: 400,
76
+ headers: { "Content-Type": "text/plain" },
77
+ });
78
+ const body = await resolveErrorBody({ response });
79
+ expect(body).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe("flattenNestedArrayErrors", () => {
84
+ it("maps row errors to RHF paths", () => {
85
+ const fields: Record<string, string | string[]> = {};
86
+ flattenNestedArrayErrors("lines", [
87
+ { label: ["Required"] },
88
+ { quantity: ["Too small", "Invalid"] },
89
+ ], fields);
90
+ expect(fields).toEqual({
91
+ "lines.0.label": "Required",
92
+ "lines.1.quantity": ["Too small", "Invalid"],
93
+ });
94
+ });
95
+ });
96
+
97
+ describe("parseDotNetFormErrors", () => {
98
+ it("maps errors object with camelCase", () => {
99
+ const parsed = parseDotNetFormErrors(
100
+ {
101
+ body: {
102
+ errors: { Email: ["The Email field is required."] },
103
+ },
104
+ },
105
+ mainCtx,
106
+ );
107
+ expect(parsed?.fields).toEqual({
108
+ email: "The Email field is required.",
109
+ });
110
+ });
111
+ });
112
+
113
+ describe("parseNodeFormErrors", () => {
114
+ it("maps express-validator array", () => {
115
+ const parsed = parseNodeFormErrors(
116
+ {
117
+ body: {
118
+ errors: [{ path: "email", msg: "Invalid email" }],
119
+ },
120
+ },
121
+ mainCtx,
122
+ );
123
+ expect(parsed?.fields).toEqual({ email: "Invalid email" });
124
+ });
125
+
126
+ it("maps Joi details", () => {
127
+ const parsed = parseNodeFormErrors(
128
+ {
129
+ body: {
130
+ details: [{ message: "Required", path: ["email"] }],
131
+ },
132
+ },
133
+ mainCtx,
134
+ );
135
+ expect(parsed?.fields).toEqual({ email: "Required" });
136
+ });
137
+ });
138
+