ding-react-admin 1.0.4 → 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 (230) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +254 -201
  3. package/dist/index.css +2 -0
  4. package/dist/index.js +2073 -1202
  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/InlineFormSet.d.ts +2 -17
  18. package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
  19. package/dist/src/crud/ResourceForm.d.ts +2 -10
  20. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  21. package/dist/src/crud/ResourceFormModal.d.ts +7 -1
  22. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  23. package/dist/src/crud/ResourceList.d.ts.map +1 -1
  24. package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
  25. package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
  26. package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
  27. package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
  28. package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
  29. package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
  30. package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
  31. package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
  32. package/dist/src/crud/fields/FileField.d.ts +12 -0
  33. package/dist/src/crud/fields/FileField.d.ts.map +1 -0
  34. package/dist/src/crud/fields/ImageField.d.ts +14 -0
  35. package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
  36. package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
  37. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  38. package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
  39. package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
  40. package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
  41. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  42. package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
  43. package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
  44. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
  45. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
  46. package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
  47. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
  48. package/dist/src/crud/index.d.ts +27 -9
  49. package/dist/src/crud/index.d.ts.map +1 -1
  50. package/dist/src/crud/types.d.ts +21 -7
  51. package/dist/src/crud/types.d.ts.map +1 -1
  52. package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
  53. package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
  54. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
  55. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
  56. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
  57. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
  58. package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
  59. package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
  60. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
  61. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
  62. package/dist/src/crud/utils/formErrors.d.ts +18 -7
  63. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  64. package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
  65. package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
  66. package/dist/src/crud/utils/getFormValue.d.ts +3 -0
  67. package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
  68. package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
  69. package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
  70. package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
  71. package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
  72. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +8 -0
  73. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
  74. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
  75. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
  76. package/dist/src/crud/utils/setFormValue.d.ts +3 -0
  77. package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
  78. package/dist/src/crud/utils/toFormData.d.ts +13 -0
  79. package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
  80. package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
  81. package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
  82. package/dist/src/crud/utils/useChoices.d.ts +27 -1
  83. package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
  84. package/dist/src/crud/utils/useFormRecord.d.ts +44 -0
  85. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
  86. package/dist/src/data/createRestResourceHandlers.d.ts +5 -5
  87. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
  88. package/dist/src/data/dataProviderTypes.d.ts +6 -5
  89. package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
  90. package/dist/src/data/parseFormErrorHelpers.d.ts +23 -6
  91. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  92. package/dist/src/data/resourceHandlers.d.ts +1 -1
  93. package/dist/src/data/resourceHandlers.d.ts.map +1 -1
  94. package/dist/src/index.d.ts +4 -4
  95. package/dist/src/index.d.ts.map +1 -1
  96. package/dist/src/layouts/AdminLayout.d.ts +1 -1
  97. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  98. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
  99. package/dist/src/layouts/navFilter.d.ts +5 -0
  100. package/dist/src/layouts/navFilter.d.ts.map +1 -0
  101. package/dist/src/layouts/navMenuItems.d.ts +6 -0
  102. package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
  103. package/dist/src/types.d.ts +12 -0
  104. package/dist/src/types.d.ts.map +1 -1
  105. package/package.json +79 -59
  106. package/src/app/AdminApp.tsx +34 -34
  107. package/src/components/AuthAlternateLink.tsx +23 -23
  108. package/src/components/DensitySwitch.tsx +31 -31
  109. package/src/components/NavMenuLabel.tsx +26 -0
  110. package/src/components/NavMenuSearch.tsx +76 -0
  111. package/src/components/ScrollableArea.tsx +46 -0
  112. package/src/components/ThemeSwitch.tsx +28 -28
  113. package/src/components/ThemeToolbar.tsx +34 -34
  114. package/src/components/index.ts +7 -4
  115. package/src/components/navMenu.css +21 -0
  116. package/src/components/scrollable.css +28 -0
  117. package/src/context/AppThemeProvider.tsx +128 -128
  118. package/src/context/AuthProvider.tsx +89 -77
  119. package/src/context/DataProvider.tsx +30 -30
  120. package/src/context/PermissionsProvider.tsx +57 -57
  121. package/src/crud/FilterBar.tsx +36 -36
  122. package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
  123. package/src/crud/FormSteps.tsx +135 -135
  124. package/src/crud/FormTabs.tsx +126 -126
  125. package/src/crud/InlineFormSet.tsx +162 -219
  126. package/src/crud/ListActionsBar.tsx +106 -106
  127. package/src/crud/ResourceForm.tsx +83 -166
  128. package/src/crud/ResourceFormModal.tsx +75 -133
  129. package/src/crud/ResourceList.tsx +1 -0
  130. package/src/crud/ResourceRecordForm.tsx +166 -0
  131. package/src/crud/columns/BooleanColumn.tsx +32 -32
  132. package/src/crud/columns/CustomColumn.tsx +33 -33
  133. package/src/crud/columns/DateColumn.tsx +28 -28
  134. package/src/crud/columns/ImageColumn.tsx +58 -0
  135. package/src/crud/columns/NumberColumn.tsx +31 -31
  136. package/src/crud/columns/ReferenceColumn.tsx +85 -85
  137. package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
  138. package/src/crud/columns/TextColumn.tsx +38 -38
  139. package/src/crud/context/FilterContext.tsx +71 -71
  140. package/src/crud/context/FormContext.tsx +39 -39
  141. package/src/crud/context/FormSectionContext.tsx +22 -22
  142. package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
  143. package/src/crud/context/ListContext.tsx +93 -93
  144. package/src/crud/context/PayloadFieldsContext.tsx +62 -0
  145. package/src/crud/fields/BooleanField.tsx +38 -38
  146. package/src/crud/fields/DateField.tsx +53 -53
  147. package/src/crud/fields/FieldWrapper.tsx +87 -87
  148. package/src/crud/fields/FileField.tsx +123 -0
  149. package/src/crud/fields/ImageField.tsx +120 -0
  150. package/src/crud/fields/NumberField.tsx +52 -52
  151. package/src/crud/fields/PasswordField.tsx +99 -99
  152. package/src/crud/fields/ReferenceField.tsx +237 -96
  153. package/src/crud/fields/ReferenceInputActions.tsx +106 -0
  154. package/src/crud/fields/ReferenceManyField.tsx +200 -76
  155. package/src/crud/fields/SelectField.tsx +48 -48
  156. package/src/crud/fields/TextField.tsx +46 -46
  157. package/src/crud/fields/uploadFieldUtils.ts +21 -0
  158. package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
  159. package/src/crud/filters/BooleanFilter.tsx +38 -38
  160. package/src/crud/filters/DateFilter.tsx +35 -35
  161. package/src/crud/filters/NumberFilter.tsx +33 -33
  162. package/src/crud/filters/ReferenceFilter.tsx +192 -173
  163. package/src/crud/filters/SelectFilter.tsx +44 -44
  164. package/src/crud/filters/TextFilter.tsx +36 -36
  165. package/src/crud/index.ts +99 -90
  166. package/src/crud/types.ts +146 -132
  167. package/src/crud/utils/buildFormPayload.ts +24 -0
  168. package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
  169. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
  170. package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
  171. package/src/crud/utils/choiceSelectionUtils.ts +116 -0
  172. package/src/crud/utils/formErrors.test.ts +168 -0
  173. package/src/crud/utils/formErrors.ts +138 -38
  174. package/src/crud/utils/formSectionErrors.ts +85 -85
  175. package/src/crud/utils/{getByPath.ts → getFormValue.ts} +13 -13
  176. package/src/crud/utils/hasUploadValues.ts +9 -0
  177. package/src/crud/utils/nestedFieldPath.ts +8 -0
  178. package/src/crud/utils/prepareFormSubmitBody.ts +22 -0
  179. package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
  180. package/src/crud/utils/{setByPath.ts → setFormValue.ts} +18 -18
  181. package/src/crud/utils/sortQueryParam.ts +37 -37
  182. package/src/crud/utils/toFormData.ts +79 -0
  183. package/src/crud/utils/uploadReferenceUtils.ts +44 -0
  184. package/src/crud/utils/useAbortableEffect.ts +17 -17
  185. package/src/crud/utils/useChoices.ts +330 -182
  186. package/src/crud/utils/useFormRecord.ts +179 -0
  187. package/src/crud/utils/useListQueryState.ts +191 -191
  188. package/src/data/abortError.test.ts +19 -19
  189. package/src/data/abortError.ts +10 -10
  190. package/src/data/createMemoryResourceHandlers.ts +78 -78
  191. package/src/data/createRestResourceHandlers.ts +63 -57
  192. package/src/data/dataProviderTypes.ts +7 -5
  193. package/src/data/dataUtils.test.ts +228 -108
  194. package/src/data/inMemoryList.ts +97 -97
  195. package/src/data/parseFormErrorHelpers.test.ts +201 -84
  196. package/src/data/parseFormErrorHelpers.ts +376 -224
  197. package/src/data/resourceHandlers.ts +1 -1
  198. package/src/data/sortHelpers.ts +48 -48
  199. package/src/index.ts +211 -195
  200. package/src/layouts/AdminLayout.tsx +530 -381
  201. package/src/layouts/AuthPageLayout.tsx +87 -81
  202. package/src/layouts/navFilter.ts +49 -0
  203. package/src/layouts/navMenuItems.tsx +44 -0
  204. package/src/pages/LoginPage.tsx +80 -80
  205. package/src/pages/PlaceholderPage.tsx +9 -9
  206. package/src/permissions/resourcePermissions.ts +47 -47
  207. package/src/router/createAdminRouter.tsx +64 -64
  208. package/src/router/guards.tsx +63 -63
  209. package/src/router/routeAccess.ts +89 -89
  210. package/src/types.ts +10 -0
  211. package/src/vite-env.d.ts +1 -1
  212. package/dist/src/crud/context/SubmitFieldsContext.d.ts +0 -18
  213. package/dist/src/crud/context/SubmitFieldsContext.d.ts.map +0 -1
  214. package/dist/src/crud/utils/getByPath.d.ts +0 -3
  215. package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
  216. package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
  217. package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
  218. package/dist/src/crud/utils/inlineFieldName.d.ts +0 -3
  219. package/dist/src/crud/utils/inlineFieldName.d.ts.map +0 -1
  220. package/dist/src/crud/utils/pickBySources.d.ts +0 -6
  221. package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
  222. package/dist/src/crud/utils/setByPath.d.ts +0 -3
  223. package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
  224. package/dist/src/crud/utils/useResourceFormData.d.ts +0 -47
  225. package/dist/src/crud/utils/useResourceFormData.d.ts.map +0 -1
  226. package/src/crud/context/SubmitFieldsContext.tsx +0 -62
  227. package/src/crud/utils/inlineArrayName.ts +0 -4
  228. package/src/crud/utils/inlineFieldName.ts +0 -8
  229. package/src/crud/utils/pickBySources.ts +0 -24
  230. package/src/crud/utils/useResourceFormData.ts +0 -231
@@ -0,0 +1,116 @@
1
+ import type { ChoiceOption } from "../types";
2
+
3
+ export function isRecordObject(
4
+ value: unknown,
5
+ optionValue: string,
6
+ ): value is Record<string, unknown> {
7
+ return (
8
+ typeof value === "object" &&
9
+ value !== null &&
10
+ !Array.isArray(value) &&
11
+ optionValue in value
12
+ );
13
+ }
14
+
15
+ export function valueAsId(
16
+ value: unknown,
17
+ optionValue: string,
18
+ ): string | number | undefined {
19
+ if (value == null || value === "") return undefined;
20
+ if (isRecordObject(value, optionValue)) {
21
+ const id = value[optionValue];
22
+ if (typeof id === "string" || typeof id === "number") return id;
23
+ return undefined;
24
+ }
25
+ if (typeof value === "string" || typeof value === "number") return value;
26
+ return undefined;
27
+ }
28
+
29
+ export function valuesAsIds(
30
+ values: unknown,
31
+ optionValue: string,
32
+ ): (string | number)[] {
33
+ if (!Array.isArray(values)) return [];
34
+ return values
35
+ .map((value) => valueAsId(value, optionValue))
36
+ .filter((value): value is string | number => value != null);
37
+ }
38
+
39
+ export function normalizeSelectedIds(
40
+ selectedValues: unknown | unknown[] | undefined,
41
+ optionValue: string,
42
+ ): unknown[] {
43
+ if (selectedValues == null) return [];
44
+ const values = Array.isArray(selectedValues)
45
+ ? selectedValues
46
+ : [selectedValues];
47
+ return values
48
+ .filter((value) => value != null && value !== "")
49
+ .map((value) =>
50
+ isRecordObject(value, optionValue) ? value[optionValue] : value,
51
+ );
52
+ }
53
+
54
+ export function collectSelectedRecords(
55
+ selectedValues: unknown | unknown[] | undefined,
56
+ selectedRecords: Record<string, unknown> | Record<string, unknown>[] | undefined,
57
+ optionValue: string,
58
+ ): Record<string, unknown>[] {
59
+ const records: Record<string, unknown>[] = [];
60
+
61
+ if (selectedRecords != null) {
62
+ if (Array.isArray(selectedRecords)) {
63
+ records.push(
64
+ ...selectedRecords.filter(
65
+ (record): record is Record<string, unknown> =>
66
+ isRecordObject(record, optionValue),
67
+ ),
68
+ );
69
+ } else if (isRecordObject(selectedRecords, optionValue)) {
70
+ records.push(selectedRecords);
71
+ }
72
+ }
73
+
74
+ if (selectedValues == null) return records;
75
+
76
+ const values = Array.isArray(selectedValues)
77
+ ? selectedValues
78
+ : [selectedValues];
79
+ for (const value of values) {
80
+ if (isRecordObject(value, optionValue)) {
81
+ records.push(value);
82
+ }
83
+ }
84
+
85
+ return records;
86
+ }
87
+
88
+ export function resolveOptionLabel(
89
+ record: Record<string, unknown>,
90
+ optionLabel: string | ((record: Record<string, unknown>) => string),
91
+ ): string {
92
+ if (typeof optionLabel === "function") return optionLabel(record);
93
+ return String(record[optionLabel] ?? "");
94
+ }
95
+
96
+ export function recordsToOptions(
97
+ records: Record<string, unknown>[],
98
+ optionLabel: string | ((record: Record<string, unknown>) => string),
99
+ optionValue: string,
100
+ ): ChoiceOption[] {
101
+ return records.map((record) => ({
102
+ label: resolveOptionLabel(record, optionLabel),
103
+ value: record[optionValue],
104
+ record,
105
+ }));
106
+ }
107
+
108
+ export function mergeOptions(
109
+ existing: ChoiceOption[],
110
+ incoming: ChoiceOption[],
111
+ ): ChoiceOption[] {
112
+ const byValue = new Map<unknown, ChoiceOption>();
113
+ for (const option of existing) byValue.set(option.value, option);
114
+ for (const option of incoming) byValue.set(option.value, option);
115
+ return Array.from(byValue.values());
116
+ }
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { UseFormReturn } from "react-hook-form";
3
+
4
+ import type { DataProvider } from "../../data/dataProviderTypes";
5
+ import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
6
+ import {
7
+ applyApiErrorsToForm,
8
+ isKnownFormFieldPath,
9
+ partitionFormErrors,
10
+ } from "./formErrors";
11
+
12
+ const inlineRegistry = new Map<string, InlineFieldRegistration>([
13
+ [
14
+ "lines",
15
+ {
16
+ field: "lines",
17
+ sources: ["label", "quantity"],
18
+ },
19
+ ],
20
+ ]);
21
+
22
+ describe("isKnownFormFieldPath", () => {
23
+ it("matches top-level payload fields", () => {
24
+ const payloadFields = new Set(["name", "email"]);
25
+ expect(isKnownFormFieldPath("name", payloadFields, inlineRegistry)).toBe(
26
+ true,
27
+ );
28
+ expect(
29
+ isKnownFormFieldPath("unknown", payloadFields, inlineRegistry),
30
+ ).toBe(false);
31
+ });
32
+
33
+ it("matches inline nested paths", () => {
34
+ const payloadFields = new Set(["lines"]);
35
+ expect(
36
+ isKnownFormFieldPath("lines.0.quantity", payloadFields, inlineRegistry),
37
+ ).toBe(true);
38
+ expect(
39
+ isKnownFormFieldPath("lines.2.label", payloadFields, inlineRegistry),
40
+ ).toBe(true);
41
+ expect(
42
+ isKnownFormFieldPath("lines.0.missing", payloadFields, inlineRegistry),
43
+ ).toBe(false);
44
+ });
45
+ });
46
+
47
+ describe("partitionFormErrors", () => {
48
+ it("keeps known fields and promotes unknown keys to global", () => {
49
+ const payloadFields = new Set(["name"]);
50
+ const { fieldErrors, globalErrors } = partitionFormErrors(
51
+ {
52
+ fields: {
53
+ name: "Required",
54
+ internal_code: "Already used",
55
+ },
56
+ global: ["Cannot save while locked"],
57
+ },
58
+ payloadFields,
59
+ inlineRegistry,
60
+ );
61
+
62
+ expect(fieldErrors).toEqual({ name: "Required" });
63
+ expect(globalErrors).toEqual([
64
+ "Cannot save while locked",
65
+ "Already used",
66
+ ]);
67
+ });
68
+
69
+ it("routes inline errors to fields when the column exists", () => {
70
+ const payloadFields = new Set(["lines"]);
71
+ const { fieldErrors, globalErrors } = partitionFormErrors(
72
+ {
73
+ fields: {
74
+ "lines.0.quantity": "Must be positive",
75
+ "lines.1.unknown": "Bad value",
76
+ },
77
+ },
78
+ payloadFields,
79
+ inlineRegistry,
80
+ );
81
+
82
+ expect(fieldErrors).toEqual({
83
+ "lines.0.quantity": "Must be positive",
84
+ });
85
+ expect(globalErrors).toEqual(["Bad value"]);
86
+ });
87
+ });
88
+
89
+ describe("applyApiErrorsToForm", () => {
90
+ const context = { resource: "users", mutation: "create" as const };
91
+ const options = {
92
+ payloadFields: new Set(["email"]),
93
+ inlineRegistry: [] as InlineFieldRegistration[],
94
+ };
95
+
96
+ function createForm() {
97
+ return {
98
+ setError: vi.fn(),
99
+ } as unknown as UseFormReturn<{ email: string }>;
100
+ }
101
+
102
+ it("maps standard validation bodies to field errors", async () => {
103
+ const form = createForm();
104
+ const dp = {
105
+ parseFormError: vi.fn().mockReturnValue({
106
+ fields: { email: "Already taken" },
107
+ }),
108
+ } as unknown as DataProvider;
109
+
110
+ const result = await applyApiErrorsToForm(
111
+ dp,
112
+ form,
113
+ {
114
+ body: { email: ["Already taken"] },
115
+ },
116
+ context,
117
+ options,
118
+ );
119
+
120
+ expect(result.handled).toBe(true);
121
+ expect(result.globalErrors).toEqual([]);
122
+ expect(form.setError).toHaveBeenCalledWith("email", {
123
+ type: "server",
124
+ message: "Already taken",
125
+ });
126
+ });
127
+
128
+ it("returns a non-standard format warning when the parser cannot map the body", async () => {
129
+ const form = createForm();
130
+ const dp = {
131
+ parseFormError: vi.fn().mockReturnValue(null),
132
+ } as unknown as DataProvider;
133
+
134
+ const result = await applyApiErrorsToForm(
135
+ dp,
136
+ form,
137
+ { body: { message: "Unexpected shape" } },
138
+ context,
139
+ options,
140
+ );
141
+
142
+ expect(result.handled).toBe(true);
143
+ expect(result.globalErrors[0]).toContain("Non-standard validation response.");
144
+ expect(result.globalErrors[0]).toContain('{"message":"Unexpected shape"}');
145
+ expect(form.setError).not.toHaveBeenCalled();
146
+ });
147
+
148
+ it("returns a non-standard format warning for HTTP 400 without JSON", async () => {
149
+ const form = createForm();
150
+ const dp = {} as DataProvider;
151
+ const response = new Response("bad request", {
152
+ status: 400,
153
+ headers: { "Content-Type": "text/plain" },
154
+ });
155
+
156
+ const result = await applyApiErrorsToForm(
157
+ dp,
158
+ form,
159
+ { response },
160
+ context,
161
+ options,
162
+ );
163
+
164
+ expect(result.handled).toBe(true);
165
+ expect(result.globalErrors[0]).toContain("Non-standard validation response.");
166
+ expect(result.globalErrors[0]).toContain("text/plain");
167
+ });
168
+ });
@@ -1,38 +1,138 @@
1
- import type { MessageInstance } from "antd/es/message/interface";
2
- import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
3
- import type { DataProvider, ParseFormErrorContext } from "../../data/dataProviderTypes";
4
-
5
- function toMessages(value: string | string[] | undefined): string[] {
6
- if (!value) return [];
7
- return Array.isArray(value) ? value : [value];
8
- }
9
-
10
- export function applyFormErrors<T extends FieldValues>(
11
- form: UseFormReturn<T>,
12
- parsed: { fields?: Record<string, string | string[]>; global?: string | string[] },
13
- message: MessageInstance,
14
- ) {
15
- for (const msg of toMessages(parsed.global)) {
16
- message.error(msg);
17
- }
18
- for (const [path, msg] of Object.entries(parsed.fields ?? {})) {
19
- const text = Array.isArray(msg) ? msg.join(", ") : msg;
20
- form.setError(path as FieldPath<T>, { type: "server", message: text });
21
- }
22
- }
23
-
24
- export function parseAndApplyFormErrors<T extends FieldValues>(
25
- dp: DataProvider,
26
- form: UseFormReturn<T>,
27
- message: MessageInstance,
28
- error: unknown,
29
- context: ParseFormErrorContext,
30
- ): boolean {
31
- const parsed = dp.parseFormError?.(error, context);
32
- if (!parsed) return false;
33
- const hasFields = Object.keys(parsed.fields ?? {}).length > 0;
34
- const hasGlobal = toMessages(parsed.global).length > 0;
35
- if (!hasFields && !hasGlobal) return false;
36
- applyFormErrors(form, parsed, message);
37
- return true;
38
- }
1
+ import type { FieldPath, FieldValues, UseFormReturn } from "react-hook-form";
2
+
3
+ import type {
4
+ DataProvider,
5
+ FormValidationErrors,
6
+ ParseFormErrorContext,
7
+ } from "../../data/dataProviderTypes";
8
+ import type { InlineFieldRegistration } from "../context/InlineFieldsRegistry";
9
+ import {
10
+ describeNonStandardValidationBody,
11
+ getErrorResponseContentType,
12
+ getValidationErrorStatus,
13
+ resolveErrorBody,
14
+ } from "../../data/parseFormErrorHelpers";
15
+
16
+ export type ApplyApiErrorsResult = {
17
+ handled: boolean;
18
+ globalErrors: string[];
19
+ };
20
+
21
+ export type ApplyApiErrorsOptions = {
22
+ payloadFields: Iterable<string>;
23
+ inlineRegistry: Iterable<InlineFieldRegistration>;
24
+ };
25
+
26
+ function toMessages(value: string | string[] | undefined): string[] {
27
+ if (!value) return [];
28
+ return Array.isArray(value) ? value : [value];
29
+ }
30
+
31
+ function messageText(msg: string | string[]): string {
32
+ return Array.isArray(msg) ? msg.join(", ") : msg;
33
+ }
34
+
35
+ /** Top-level `source` or inline cell path such as `lines.0.quantity`. */
36
+ export function isKnownFormFieldPath(
37
+ path: string,
38
+ payloadFields: Set<string>,
39
+ inlineRegistry: Map<string, InlineFieldRegistration>,
40
+ ): boolean {
41
+ if (payloadFields.has(path)) return true;
42
+
43
+ const match = path.match(/^([^.]+)\.(\d+)\.([^.]+)$/);
44
+ if (!match) return false;
45
+
46
+ const [, arrayName, , source] = match;
47
+ return inlineRegistry.get(arrayName)?.sources.includes(source) ?? false;
48
+ }
49
+
50
+ export function partitionFormErrors(
51
+ parsed: FormValidationErrors,
52
+ payloadFields: Set<string>,
53
+ inlineRegistry: Map<string, InlineFieldRegistration>,
54
+ ): { fieldErrors: Record<string, string | string[]>; globalErrors: string[] } {
55
+ const fieldErrors: Record<string, string | string[]> = {};
56
+ const globalErrors = [...toMessages(parsed.global)];
57
+
58
+ for (const [path, msg] of Object.entries(parsed.fields ?? {})) {
59
+ if (isKnownFormFieldPath(path, payloadFields, inlineRegistry)) {
60
+ fieldErrors[path] = msg;
61
+ } else {
62
+ globalErrors.push(messageText(msg));
63
+ }
64
+ }
65
+
66
+ return { fieldErrors, globalErrors };
67
+ }
68
+
69
+ export function applyFormErrors<T extends FieldValues>(
70
+ form: UseFormReturn<T>,
71
+ fieldErrors: Record<string, string | string[]>,
72
+ ) {
73
+ for (const [path, msg] of Object.entries(fieldErrors)) {
74
+ form.setError(path as FieldPath<T>, {
75
+ type: "server",
76
+ message: messageText(msg),
77
+ });
78
+ }
79
+ }
80
+
81
+ function nonStandardValidationHint(error: unknown): string | undefined {
82
+ const contentType = getErrorResponseContentType(error);
83
+ if (contentType && !/application\/json/i.test(contentType)) {
84
+ return `non-JSON response (Content-Type: ${contentType})`;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ export async function applyApiErrorsToForm<T extends FieldValues>(
90
+ dp: DataProvider,
91
+ form: UseFormReturn<T>,
92
+ error: unknown,
93
+ context: ParseFormErrorContext,
94
+ options: ApplyApiErrorsOptions,
95
+ ): Promise<ApplyApiErrorsResult> {
96
+ const body = await resolveErrorBody(error);
97
+
98
+ if (body != null) {
99
+ const parsed = dp.parseFormError?.({ body }, context);
100
+
101
+ if (parsed) {
102
+ const payloadFields = new Set(options.payloadFields);
103
+ const inlineRegistry = new Map<string, InlineFieldRegistration>();
104
+ for (const inline of options.inlineRegistry) {
105
+ inlineRegistry.set(inline.field, inline);
106
+ }
107
+
108
+ const { fieldErrors, globalErrors } = partitionFormErrors(
109
+ parsed,
110
+ payloadFields,
111
+ inlineRegistry,
112
+ );
113
+
114
+ if (Object.keys(fieldErrors).length || globalErrors.length) {
115
+ applyFormErrors(form, fieldErrors);
116
+ return { handled: true, globalErrors };
117
+ }
118
+ }
119
+
120
+ return {
121
+ handled: true,
122
+ globalErrors: [describeNonStandardValidationBody(body)],
123
+ };
124
+ }
125
+
126
+ if (getValidationErrorStatus(error) != null) {
127
+ return {
128
+ handled: true,
129
+ globalErrors: [
130
+ describeNonStandardValidationBody(null, {
131
+ hint: nonStandardValidationHint(error),
132
+ }),
133
+ ],
134
+ };
135
+ }
136
+
137
+ return { handled: false, globalErrors: [] };
138
+ }
@@ -1,85 +1,85 @@
1
- import { useEffect, useRef, type MutableRefObject } from "react";
2
- import {
3
- useFormContext,
4
- useFormState,
5
- type FieldPath,
6
- type FieldValues,
7
- type FormState,
8
- type UseFormGetFieldState,
9
- } from "react-hook-form";
10
-
11
- export function sectionHasError<T extends FieldValues>(
12
- sources: Set<string>,
13
- getFieldState: UseFormGetFieldState<T>,
14
- formState: FormState<T>,
15
- ): boolean {
16
- for (const source of sources) {
17
- if (getFieldState(source as FieldPath<T>, formState).invalid) {
18
- return true;
19
- }
20
- }
21
- return false;
22
- }
23
-
24
- export function useSectionSourceRefs(count: number) {
25
- const refs = useRef<MutableRefObject<Set<string>>[]>([]);
26
-
27
- while (refs.current.length < count) {
28
- refs.current.push({ current: new Set() });
29
- }
30
- if (refs.current.length > count) {
31
- refs.current.length = count;
32
- }
33
-
34
- return refs.current;
35
- }
36
-
37
- export function useActivateFirstErrorSection(
38
- sectionRefs: MutableRefObject<Set<string>>[],
39
- activate: (index: number) => void,
40
- ) {
41
- const { control, getFieldState, setFocus } = useFormContext<FieldValues>();
42
- const formState = useFormState({ control });
43
- const prevSubmitCount = useRef(0);
44
- const prevErrorCount = useRef(0);
45
-
46
- useEffect(() => {
47
- if (formState.submitCount === 0) return;
48
-
49
- const errorCount = Object.keys(formState.errors).length;
50
- const submitChanged = formState.submitCount !== prevSubmitCount.current;
51
- const serverErrorsArrived =
52
- !submitChanged && errorCount > 0 && prevErrorCount.current === 0;
53
-
54
- prevSubmitCount.current = formState.submitCount;
55
- prevErrorCount.current = errorCount;
56
-
57
- if ((!submitChanged && !serverErrorsArrived) || errorCount === 0) {
58
- return;
59
- }
60
-
61
- const first = sectionRefs.findIndex((ref) =>
62
- sectionHasError(ref.current, getFieldState, formState),
63
- );
64
- if (first < 0) return;
65
-
66
- activate(first);
67
-
68
- const firstInvalid = [...sectionRefs[first].current].find((source) =>
69
- getFieldState(source as FieldPath<FieldValues>, formState).invalid,
70
- );
71
- if (firstInvalid) {
72
- requestAnimationFrame(() => {
73
- void setFocus(firstInvalid as FieldPath<FieldValues>);
74
- });
75
- }
76
- }, [
77
- activate,
78
- formState,
79
- formState.errors,
80
- formState.submitCount,
81
- getFieldState,
82
- sectionRefs,
83
- setFocus,
84
- ]);
85
- }
1
+ import { useEffect, useRef, type MutableRefObject } from "react";
2
+ import {
3
+ useFormContext,
4
+ useFormState,
5
+ type FieldPath,
6
+ type FieldValues,
7
+ type FormState,
8
+ type UseFormGetFieldState,
9
+ } from "react-hook-form";
10
+
11
+ export function sectionHasError<T extends FieldValues>(
12
+ sources: Set<string>,
13
+ getFieldState: UseFormGetFieldState<T>,
14
+ formState: FormState<T>,
15
+ ): boolean {
16
+ for (const source of sources) {
17
+ if (getFieldState(source as FieldPath<T>, formState).invalid) {
18
+ return true;
19
+ }
20
+ }
21
+ return false;
22
+ }
23
+
24
+ export function useSectionSourceRefs(count: number) {
25
+ const refs = useRef<MutableRefObject<Set<string>>[]>([]);
26
+
27
+ while (refs.current.length < count) {
28
+ refs.current.push({ current: new Set() });
29
+ }
30
+ if (refs.current.length > count) {
31
+ refs.current.length = count;
32
+ }
33
+
34
+ return refs.current;
35
+ }
36
+
37
+ export function useActivateFirstErrorSection(
38
+ sectionRefs: MutableRefObject<Set<string>>[],
39
+ activate: (index: number) => void,
40
+ ) {
41
+ const { control, getFieldState, setFocus } = useFormContext<FieldValues>();
42
+ const formState = useFormState({ control });
43
+ const prevSubmitCount = useRef(0);
44
+ const prevErrorCount = useRef(0);
45
+
46
+ useEffect(() => {
47
+ if (formState.submitCount === 0) return;
48
+
49
+ const errorCount = Object.keys(formState.errors).length;
50
+ const submitChanged = formState.submitCount !== prevSubmitCount.current;
51
+ const serverErrorsArrived =
52
+ !submitChanged && errorCount > 0 && prevErrorCount.current === 0;
53
+
54
+ prevSubmitCount.current = formState.submitCount;
55
+ prevErrorCount.current = errorCount;
56
+
57
+ if ((!submitChanged && !serverErrorsArrived) || errorCount === 0) {
58
+ return;
59
+ }
60
+
61
+ const first = sectionRefs.findIndex((ref) =>
62
+ sectionHasError(ref.current, getFieldState, formState),
63
+ );
64
+ if (first < 0) return;
65
+
66
+ activate(first);
67
+
68
+ const firstInvalid = [...sectionRefs[first].current].find((source) =>
69
+ getFieldState(source as FieldPath<FieldValues>, formState).invalid,
70
+ );
71
+ if (firstInvalid) {
72
+ requestAnimationFrame(() => {
73
+ void setFocus(firstInvalid as FieldPath<FieldValues>);
74
+ });
75
+ }
76
+ }, [
77
+ activate,
78
+ formState,
79
+ formState.errors,
80
+ formState.submitCount,
81
+ getFieldState,
82
+ sectionRefs,
83
+ setFocus,
84
+ ]);
85
+ }
@@ -1,13 +1,13 @@
1
- /** Read nested value from a record using dot notation, e.g. `brand.name`. */
2
- export function getByPath(
3
- record: Record<string, unknown>,
4
- path: string,
5
- ): unknown {
6
- const parts = path.split(".");
7
- let cur: unknown = record;
8
- for (const part of parts) {
9
- if (cur == null || typeof cur !== "object") return undefined;
10
- cur = (cur as Record<string, unknown>)[part];
11
- }
12
- return cur;
13
- }
1
+ /** Read a nested value from form values or a record, e.g. `lines.0.label`. */
2
+ export function getFormValue(
3
+ record: Record<string, unknown>,
4
+ path: string,
5
+ ): unknown {
6
+ const parts = path.split(".");
7
+ let cur: unknown = record;
8
+ for (const part of parts) {
9
+ if (cur == null || typeof cur !== "object") return undefined;
10
+ cur = (cur as Record<string, unknown>)[part];
11
+ }
12
+ return cur;
13
+ }
@@ -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,8 @@
1
+ /** RHF path for one cell in a nested field array row, e.g. `lines.0.label`. */
2
+ export function nestedFieldPath(
3
+ field: string,
4
+ index: number,
5
+ source: string,
6
+ ): string {
7
+ return `${field}.${index}.${source}`;
8
+ }