ding-react-admin 1.0.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +38 -13
  2. package/dist/index.js +1317 -1060
  3. package/dist/src/crud/InlineFormSet.d.ts +12 -25
  4. package/dist/src/crud/InlineFormSet.d.ts.map +1 -1
  5. package/dist/src/crud/ResourceForm.d.ts +7 -4
  6. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  7. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  8. package/dist/src/crud/ResourceList.d.ts.map +1 -1
  9. package/dist/src/crud/columns/ImageColumn.d.ts +14 -0
  10. package/dist/src/crud/columns/ImageColumn.d.ts.map +1 -0
  11. package/dist/src/crud/context/InlineFieldsRegistry.d.ts +15 -0
  12. package/dist/src/crud/context/InlineFieldsRegistry.d.ts.map +1 -0
  13. package/dist/src/crud/context/PayloadFieldsContext.d.ts +18 -0
  14. package/dist/src/crud/context/PayloadFieldsContext.d.ts.map +1 -0
  15. package/dist/src/crud/fields/BooleanField.d.ts +3 -1
  16. package/dist/src/crud/fields/BooleanField.d.ts.map +1 -1
  17. package/dist/src/crud/fields/DateField.d.ts +3 -1
  18. package/dist/src/crud/fields/DateField.d.ts.map +1 -1
  19. package/dist/src/crud/fields/FieldWrapper.d.ts +7 -14
  20. package/dist/src/crud/fields/FieldWrapper.d.ts.map +1 -1
  21. package/dist/src/crud/fields/FileField.d.ts +12 -0
  22. package/dist/src/crud/fields/FileField.d.ts.map +1 -0
  23. package/dist/src/crud/fields/ImageField.d.ts +14 -0
  24. package/dist/src/crud/fields/ImageField.d.ts.map +1 -0
  25. package/dist/src/crud/fields/NumberField.d.ts +5 -3
  26. package/dist/src/crud/fields/NumberField.d.ts.map +1 -1
  27. package/dist/src/crud/fields/PasswordField.d.ts +5 -3
  28. package/dist/src/crud/fields/PasswordField.d.ts.map +1 -1
  29. package/dist/src/crud/fields/ReferenceField.d.ts +6 -5
  30. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  31. package/dist/src/crud/fields/ReferenceManyField.d.ts +3 -1
  32. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  33. package/dist/src/crud/fields/SelectField.d.ts +3 -1
  34. package/dist/src/crud/fields/SelectField.d.ts.map +1 -1
  35. package/dist/src/crud/fields/TextField.d.ts +5 -3
  36. package/dist/src/crud/fields/TextField.d.ts.map +1 -1
  37. package/dist/src/crud/fields/uploadFieldUtils.d.ts +5 -0
  38. package/dist/src/crud/fields/uploadFieldUtils.d.ts.map +1 -0
  39. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts +4 -0
  40. package/dist/src/crud/fields/useUploadPreviewUrl.d.ts.map +1 -0
  41. package/dist/src/crud/index.d.ts +26 -9
  42. package/dist/src/crud/index.d.ts.map +1 -1
  43. package/dist/src/crud/types.d.ts +23 -20
  44. package/dist/src/crud/types.d.ts.map +1 -1
  45. package/dist/src/crud/utils/buildFormPayload.d.ts +6 -0
  46. package/dist/src/crud/utils/buildFormPayload.d.ts.map +1 -0
  47. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts +6 -0
  48. package/dist/src/crud/utils/buildInlineRowsPayload.d.ts.map +1 -0
  49. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts +5 -0
  50. package/dist/src/crud/utils/buildResourceFormSubmitBody.d.ts.map +1 -0
  51. package/dist/src/crud/utils/formErrors.d.ts +1 -1
  52. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  53. package/dist/src/crud/utils/getFormValue.d.ts +3 -0
  54. package/dist/src/crud/utils/getFormValue.d.ts.map +1 -0
  55. package/dist/src/crud/utils/hasUploadValues.d.ts +3 -0
  56. package/dist/src/crud/utils/hasUploadValues.d.ts.map +1 -0
  57. package/dist/src/crud/utils/nestedFieldPath.d.ts +3 -0
  58. package/dist/src/crud/utils/nestedFieldPath.d.ts.map +1 -0
  59. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +7 -0
  60. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -0
  61. package/dist/src/crud/utils/setFormValue.d.ts +3 -0
  62. package/dist/src/crud/utils/setFormValue.d.ts.map +1 -0
  63. package/dist/src/crud/utils/toFormData.d.ts +13 -0
  64. package/dist/src/crud/utils/toFormData.d.ts.map +1 -0
  65. package/dist/src/crud/utils/useAbortableEffect.d.ts +7 -0
  66. package/dist/src/crud/utils/useAbortableEffect.d.ts.map +1 -0
  67. package/dist/src/crud/utils/useFormRecord.d.ts +42 -0
  68. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -0
  69. package/dist/src/data/abortError.d.ts +3 -0
  70. package/dist/src/data/abortError.d.ts.map +1 -0
  71. package/dist/src/data/abortError.test.d.ts +2 -0
  72. package/dist/src/data/abortError.test.d.ts.map +1 -0
  73. package/dist/src/data/createRestResourceHandlers.d.ts +6 -6
  74. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -1
  75. package/dist/src/data/dataProviderTypes.d.ts +13 -6
  76. package/dist/src/data/dataProviderTypes.d.ts.map +1 -1
  77. package/dist/src/data/parseFormErrorHelpers.d.ts +44 -6
  78. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  79. package/dist/src/data/resourceHandlers.d.ts +3 -3
  80. package/dist/src/data/resourceHandlers.d.ts.map +1 -1
  81. package/dist/src/index.d.ts +5 -4
  82. package/dist/src/index.d.ts.map +1 -1
  83. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  84. package/package.json +59 -59
  85. package/src/crud/InlineFormSet.tsx +90 -159
  86. package/src/crud/ResourceForm.tsx +83 -204
  87. package/src/crud/ResourceFormModal.tsx +57 -45
  88. package/src/crud/ResourceList.tsx +36 -26
  89. package/src/crud/columns/ImageColumn.tsx +58 -0
  90. package/src/crud/columns/TextColumn.tsx +2 -2
  91. package/src/crud/context/InlineFieldsRegistry.tsx +61 -0
  92. package/src/crud/context/PayloadFieldsContext.tsx +62 -0
  93. package/src/crud/fields/BooleanField.tsx +22 -16
  94. package/src/crud/fields/DateField.tsx +32 -26
  95. package/src/crud/fields/FieldWrapper.tsx +87 -109
  96. package/src/crud/fields/FileField.tsx +123 -0
  97. package/src/crud/fields/ImageField.tsx +120 -0
  98. package/src/crud/fields/NumberField.tsx +36 -34
  99. package/src/crud/fields/PasswordField.tsx +41 -40
  100. package/src/crud/fields/ReferenceField.tsx +35 -130
  101. package/src/crud/fields/ReferenceManyField.tsx +31 -25
  102. package/src/crud/fields/SelectField.tsx +6 -0
  103. package/src/crud/fields/TextField.tsx +32 -30
  104. package/src/crud/fields/uploadFieldUtils.ts +21 -0
  105. package/src/crud/fields/useUploadPreviewUrl.ts +20 -0
  106. package/src/crud/index.ts +35 -14
  107. package/src/crud/types.ts +134 -130
  108. package/src/crud/utils/buildFormPayload.ts +24 -0
  109. package/src/crud/utils/buildInlineRowsPayload.ts +37 -0
  110. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -0
  111. package/src/crud/utils/formErrors.ts +42 -38
  112. package/src/crud/utils/{getByPath.ts → getFormValue.ts} +2 -2
  113. package/src/crud/utils/hasUploadValues.ts +9 -0
  114. package/src/crud/utils/nestedFieldPath.ts +8 -0
  115. package/src/crud/utils/prepareFormSubmitBody.ts +17 -0
  116. package/src/crud/utils/{setByPath.ts → setFormValue.ts} +2 -2
  117. package/src/crud/utils/toFormData.ts +81 -0
  118. package/src/crud/utils/useAbortableEffect.ts +17 -0
  119. package/src/crud/utils/useFormRecord.ts +156 -0
  120. package/src/data/abortError.test.ts +19 -0
  121. package/src/data/abortError.ts +10 -0
  122. package/src/data/createMemoryResourceHandlers.ts +1 -1
  123. package/src/data/createRestResourceHandlers.ts +22 -16
  124. package/src/data/dataProviderTypes.ts +15 -5
  125. package/src/data/dataUtils.test.ts +118 -6
  126. package/src/data/parseFormErrorHelpers.test.ts +138 -84
  127. package/src/data/parseFormErrorHelpers.ts +311 -224
  128. package/src/data/resourceHandlers.ts +5 -4
  129. package/src/index.ts +34 -9
  130. package/src/layouts/AdminLayout.tsx +2 -1
  131. package/dist/src/crud/context/FormFieldsContext.d.ts +0 -14
  132. package/dist/src/crud/context/FormFieldsContext.d.ts.map +0 -1
  133. package/dist/src/crud/context/InlineFormContext.d.ts +0 -16
  134. package/dist/src/crud/context/InlineFormContext.d.ts.map +0 -1
  135. package/dist/src/crud/fields/useInlineOrFormField.d.ts +0 -10
  136. package/dist/src/crud/fields/useInlineOrFormField.d.ts.map +0 -1
  137. package/dist/src/crud/utils/getByPath.d.ts +0 -3
  138. package/dist/src/crud/utils/getByPath.d.ts.map +0 -1
  139. package/dist/src/crud/utils/inlineArrayName.d.ts +0 -3
  140. package/dist/src/crud/utils/inlineArrayName.d.ts.map +0 -1
  141. package/dist/src/crud/utils/pickBySources.d.ts +0 -6
  142. package/dist/src/crud/utils/pickBySources.d.ts.map +0 -1
  143. package/dist/src/crud/utils/setByPath.d.ts +0 -3
  144. package/dist/src/crud/utils/setByPath.d.ts.map +0 -1
  145. package/src/crud/context/FormFieldsContext.tsx +0 -76
  146. package/src/crud/context/InlineFormContext.tsx +0 -71
  147. package/src/crud/fields/useInlineOrFormField.tsx +0 -91
  148. package/src/crud/utils/inlineArrayName.ts +0 -4
  149. package/src/crud/utils/pickBySources.ts +0 -24
@@ -1,224 +1,311 @@
1
- import type {
2
- FormValidationErrors,
3
- ParseFormErrorContext,
4
- } from "./dataProviderTypes";
5
-
6
- export function asStringMessages(value: unknown): string[] {
7
- if (typeof value === "string") return [value];
8
- if (Array.isArray(value)) {
9
- const strings = value.filter((x) => typeof x === "string") as string[];
10
- if (strings.length) return strings;
11
- }
12
- return [];
13
- }
14
-
15
- function toFieldValue(msgs: string[]): string | string[] {
16
- return msgs.length === 1 ? msgs[0] : msgs;
17
- }
18
-
19
- /** Read JSON from `throw { body }` or axios-style `{ response: { data } }`. */
20
- export function getErrorBody(error: unknown): Record<string, unknown> | null {
21
- if (!error || typeof error !== "object") return null;
22
- const e = error as Record<string, unknown>;
23
- if (e.body && typeof e.body === "object" && !Array.isArray(e.body)) {
24
- return e.body as Record<string, unknown>;
25
- }
26
- const response = e.response;
27
- if (response && typeof response === "object" && !Array.isArray(response)) {
28
- const data = (response as Record<string, unknown>).data;
29
- if (data && typeof data === "object" && !Array.isArray(data)) {
30
- return data as Record<string, unknown>;
31
- }
32
- }
33
- return null;
34
- }
35
-
36
- export function applyInlineFieldPaths(
37
- fields: Record<string, string | string[]>,
38
- ctx: ParseFormErrorContext,
39
- ): Record<string, string | string[]> {
40
- if (ctx.inlineArrayName == null || ctx.rowIndex == null) return fields;
41
- const rowPrefix = `${ctx.inlineArrayName}.${ctx.rowIndex}.`;
42
- const out: Record<string, string | string[]> = {};
43
- for (const [source, message] of Object.entries(fields)) {
44
- out[rowPrefix + source] = message;
45
- }
46
- return out;
47
- }
48
-
49
- export function finalizeFormErrors(
50
- fields: Record<string, string | string[]>,
51
- global: string[],
52
- ctx: ParseFormErrorContext,
53
- ): FormValidationErrors {
54
- const fieldErrors = applyInlineFieldPaths(fields, ctx);
55
- return {
56
- fields: Object.keys(fieldErrors).length ? fieldErrors : undefined,
57
- global: global.length ? global : undefined,
58
- };
59
- }
60
-
61
- const DRF_GLOBAL_KEYS = new Set(["non_field_errors", "detail"]);
62
-
63
- /**
64
- * Django REST framework validation body:
65
- * `{ "email": ["Invalid"], "non_field_errors": ["…"] }`
66
- */
67
- export function parseDjangoDRFFormErrors(
68
- error: unknown,
69
- ctx: ParseFormErrorContext,
70
- ): FormValidationErrors | null {
71
- const body = getErrorBody(error);
72
- if (!body) return null;
73
-
74
- const fields: Record<string, string | string[]> = {};
75
- const global: string[] = [];
76
-
77
- for (const [key, value] of Object.entries(body)) {
78
- if (DRF_GLOBAL_KEYS.has(key)) {
79
- global.push(...asStringMessages(value));
80
- continue;
81
- }
82
- const msgs = asStringMessages(value);
83
- if (msgs.length) fields[key] = toFieldValue(msgs);
84
- }
85
-
86
- if (!Object.keys(fields).length && !global.length) return null;
87
- return finalizeFormErrors(fields, global, ctx);
88
- }
89
-
90
- export type DotNetFormErrorOptions = {
91
- /** Map API property names to form `source` paths. */
92
- fieldMap?: Record<string, string>;
93
- /** `Email` → `email`. Default true. */
94
- camelCase?: boolean;
95
- /** Include `title` / `message` in global toasts. Default false (often generic). */
96
- includeSummary?: boolean;
97
- };
98
-
99
- /**
100
- * ASP.NET Core `ValidationProblemDetails`:
101
- * `{ "errors": { "Email": ["The Email field is required."] } }`
102
- */
103
- export function parseDotNetFormErrors(
104
- error: unknown,
105
- ctx: ParseFormErrorContext,
106
- options?: DotNetFormErrorOptions,
107
- ): FormValidationErrors | null {
108
- const body = getErrorBody(error);
109
- if (!body) return null;
110
-
111
- const camelCase = options?.camelCase ?? true;
112
- const fieldMap = options?.fieldMap;
113
- const fields: Record<string, string | string[]> = {};
114
- const global: string[] = [];
115
-
116
- if (options?.includeSummary) {
117
- global.push(...asStringMessages(body.title));
118
- global.push(...asStringMessages(body.message));
119
- }
120
-
121
- const errors = body.errors;
122
- if (errors && typeof errors === "object" && !Array.isArray(errors)) {
123
- for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
124
- const mappedKey =
125
- fieldMap?.[key] ?? (camelCase ? camelCaseField(key) : key);
126
- const msgs = asStringMessages(value);
127
- if (msgs.length) fields[mappedKey] = toFieldValue(msgs);
128
- }
129
- }
130
-
131
- if (!Object.keys(fields).length && !global.length) return null;
132
- return finalizeFormErrors(fields, global, ctx);
133
- }
134
-
135
- export type NodeFormErrorOptions = {
136
- fieldMap?: Record<string, string>;
137
- };
138
-
139
- /**
140
- * Common Node / Express shapes:
141
- * - `{ errors: { email: ["Invalid"] } }`
142
- * - `{ errors: [{ path: "email", msg: "Invalid" }] }` (express-validator)
143
- * - `{ details: [{ message: "…", path: ["email"] }] }` (Joi)
144
- */
145
- export function parseNodeFormErrors(
146
- error: unknown,
147
- ctx: ParseFormErrorContext,
148
- options?: NodeFormErrorOptions,
149
- ): FormValidationErrors | null {
150
- const body = getErrorBody(error);
151
- if (!body) return null;
152
-
153
- const fields: Record<string, string | string[]> = {};
154
- const global: string[] = [];
155
- const fieldMap = options?.fieldMap;
156
-
157
- const errList = body.errors;
158
- if (Array.isArray(errList)) {
159
- for (const item of errList) {
160
- if (!item || typeof item !== "object") continue;
161
- const row = item as Record<string, unknown>;
162
- const path =
163
- (typeof row.path === "string" && row.path) ||
164
- (typeof row.param === "string" && row.param) ||
165
- (typeof row.field === "string" && row.field);
166
- const msg =
167
- asStringMessages(row.msg)[0] ?? asStringMessages(row.message)[0];
168
- if (!msg) continue;
169
- if (path) {
170
- const key = fieldMap?.[path] ?? path;
171
- mergeFieldMessage(fields, key, msg);
172
- } else {
173
- global.push(msg);
174
- }
175
- }
176
- } else if (errList && typeof errList === "object") {
177
- for (const [key, value] of Object.entries(errList as Record<string, unknown>)) {
178
- const mappedKey = fieldMap?.[key] ?? key;
179
- const msgs = asStringMessages(value);
180
- if (msgs.length) fields[mappedKey] = toFieldValue(msgs);
181
- }
182
- }
183
-
184
- const details = body.details;
185
- if (Array.isArray(details)) {
186
- for (const item of details) {
187
- if (!item || typeof item !== "object") continue;
188
- const row = item as Record<string, unknown>;
189
- const pathParts = Array.isArray(row.path) ? row.path : [];
190
- const path = pathParts.map((p) => String(p)).join(".");
191
- const msg = asStringMessages(row.message)[0];
192
- if (!msg) continue;
193
- if (path) {
194
- const key = fieldMap?.[path] ?? path;
195
- fields[key] = msg;
196
- } else {
197
- global.push(msg);
198
- }
199
- }
200
- }
201
-
202
- global.push(...asStringMessages(body.error));
203
-
204
- if (!Object.keys(fields).length && !global.length) return null;
205
- return finalizeFormErrors(fields, global, ctx);
206
- }
207
-
208
- function mergeFieldMessage(
209
- fields: Record<string, string | string[]>,
210
- key: string,
211
- msg: string,
212
- ) {
213
- const existing = fields[key];
214
- if (!existing) {
215
- fields[key] = msg;
216
- return;
217
- }
218
- fields[key] = Array.isArray(existing) ? [...existing, msg] : [existing, msg];
219
- }
220
-
221
- function camelCaseField(key: string): string {
222
- if (!key) return key;
223
- return key.charAt(0).toLowerCase() + key.slice(1);
224
- }
1
+ import type {
2
+ FormValidationErrors,
3
+ ParseFormErrorContext,
4
+ } from "./dataProviderTypes";
5
+
6
+ export function asStringMessages(value: unknown): string[] {
7
+ if (typeof value === "string") return [value];
8
+ if (Array.isArray(value)) {
9
+ const strings = value.filter((x) => typeof x === "string") as string[];
10
+ if (strings.length) return strings;
11
+ }
12
+ return [];
13
+ }
14
+
15
+ function toFieldValue(msgs: string[]): string | string[] {
16
+ return msgs.length === 1 ? msgs[0] : msgs;
17
+ }
18
+
19
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
20
+ return value !== null && typeof value === "object" && !Array.isArray(value);
21
+ }
22
+
23
+ function isFetchResponse(value: unknown): value is Response {
24
+ if (typeof Response !== "undefined" && value instanceof Response) {
25
+ return true;
26
+ }
27
+ return (
28
+ value !== null &&
29
+ typeof value === "object" &&
30
+ typeof (value as Response).json === "function" &&
31
+ typeof (value as Response).status === "number" &&
32
+ (value as Response).headers != null
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Read a parsed JSON validation body when it is already available on the error.
38
+ *
39
+ * Supported transport shapes (sync only):
40
+ * - `{ body: { email: ["…"] } }` — explicit throw from a handler
41
+ * - `{ response: { data: { email: ["…"] } } }` — axios-style wrappers
42
+ * - `{ data: { email: ["…"] } }` — some HTTP clients attach parsed JSON here
43
+ *
44
+ * For fetch / OpenAPI clients that only expose a `Response`, use `resolveErrorBody`.
45
+ */
46
+ export function getErrorBody(error: unknown): Record<string, unknown> | null {
47
+ if (!error || typeof error !== "object") return null;
48
+ const e = error as Record<string, unknown>;
49
+
50
+ if (isJsonRecord(e.body)) {
51
+ return e.body;
52
+ }
53
+
54
+ if (isJsonRecord(e.data)) {
55
+ return e.data;
56
+ }
57
+
58
+ const response = e.response;
59
+ if (response && typeof response === "object" && !Array.isArray(response)) {
60
+ const data = (response as Record<string, unknown>).data;
61
+ if (isJsonRecord(data)) {
62
+ return data;
63
+ }
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * Resolve a validation JSON body from any common HTTP client error shape.
71
+ * Falls back to reading `response.json()` for fetch / OpenAPI `ResponseError`.
72
+ */
73
+ export async function resolveErrorBody(
74
+ error: unknown,
75
+ ): Promise<Record<string, unknown> | null> {
76
+ const syncBody = getErrorBody(error);
77
+ if (syncBody) return syncBody;
78
+
79
+ if (!error || typeof error !== "object") return null;
80
+ const response = (error as Record<string, unknown>).response;
81
+ if (!isFetchResponse(response)) return null;
82
+
83
+ const contentType = response.headers.get("content-type");
84
+ if (!contentType || !/application\/json/i.test(contentType)) {
85
+ return null;
86
+ }
87
+
88
+ try {
89
+ const body: unknown = await response.clone().json();
90
+ return isJsonRecord(body) ? body : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function isNestedRowErrorsArray(value: unknown): boolean {
97
+ if (!Array.isArray(value)) return false;
98
+ return value.some(
99
+ (item) =>
100
+ item &&
101
+ typeof item === "object" &&
102
+ !Array.isArray(item) &&
103
+ Object.values(item as Record<string, unknown>).some(
104
+ (v) => asStringMessages(v).length > 0,
105
+ ),
106
+ );
107
+ }
108
+
109
+ /** Map `{ lines: [{ label: ["…"] }] }` → `{ "lines.0.label": "…" }`. */
110
+ export function flattenNestedArrayErrors(
111
+ arrayKey: string,
112
+ rows: unknown[],
113
+ fields: Record<string, string | string[]>,
114
+ ): void {
115
+ rows.forEach((row, index) => {
116
+ if (!row || typeof row !== "object" || Array.isArray(row)) return;
117
+ for (const [source, value] of Object.entries(
118
+ row as Record<string, unknown>,
119
+ )) {
120
+ const msgs = asStringMessages(value);
121
+ if (msgs.length) {
122
+ fields[`${arrayKey}.${index}.${source}`] = toFieldValue(msgs);
123
+ }
124
+ }
125
+ });
126
+ }
127
+
128
+ export function finalizeFormErrors(
129
+ fields: Record<string, string | string[]>,
130
+ global: string[],
131
+ ): FormValidationErrors {
132
+ return {
133
+ fields: Object.keys(fields).length ? fields : undefined,
134
+ global: global.length ? global : undefined,
135
+ };
136
+ }
137
+
138
+ const DRF_GLOBAL_KEYS = new Set(["non_field_errors", "detail"]);
139
+
140
+ function parseDjangoDRFValidationBody(
141
+ body: Record<string, unknown>,
142
+ ): FormValidationErrors | null {
143
+ const fields: Record<string, string | string[]> = {};
144
+ const global: string[] = [];
145
+
146
+ for (const [key, value] of Object.entries(body)) {
147
+ if (DRF_GLOBAL_KEYS.has(key)) {
148
+ global.push(...asStringMessages(value));
149
+ continue;
150
+ }
151
+ if (isNestedRowErrorsArray(value)) {
152
+ flattenNestedArrayErrors(key, value as unknown[], fields);
153
+ continue;
154
+ }
155
+ const msgs = asStringMessages(value);
156
+ if (msgs.length) fields[key] = toFieldValue(msgs);
157
+ }
158
+
159
+ if (!Object.keys(fields).length && !global.length) return null;
160
+ return finalizeFormErrors(fields, global);
161
+ }
162
+
163
+ /**
164
+ * Django REST framework validation body:
165
+ * `{ "email": ["Invalid"], "non_field_errors": ["…"] }`
166
+ */
167
+ export function parseDjangoDRFFormErrors(
168
+ error: unknown,
169
+ _ctx: ParseFormErrorContext,
170
+ ): FormValidationErrors | null {
171
+ const body = getErrorBody(error);
172
+ if (!body) return null;
173
+ return parseDjangoDRFValidationBody(body);
174
+ }
175
+
176
+ export type DotNetFormErrorOptions = {
177
+ /** Map API property names to form `source` paths. */
178
+ fieldMap?: Record<string, string>;
179
+ /** `Email` `email`. Default true. */
180
+ camelCase?: boolean;
181
+ /** Include `title` / `message` in global toasts. Default false (often generic). */
182
+ includeSummary?: boolean;
183
+ };
184
+
185
+ /**
186
+ * ASP.NET Core `ValidationProblemDetails`:
187
+ * `{ "errors": { "Email": ["The Email field is required."] } }`
188
+ */
189
+ export function parseDotNetFormErrors(
190
+ error: unknown,
191
+ _ctx: ParseFormErrorContext,
192
+ options?: DotNetFormErrorOptions,
193
+ ): FormValidationErrors | null {
194
+ const body = getErrorBody(error);
195
+ if (!body) return null;
196
+
197
+ const camelCase = options?.camelCase ?? true;
198
+ const fieldMap = options?.fieldMap;
199
+ const fields: Record<string, string | string[]> = {};
200
+ const global: string[] = [];
201
+
202
+ if (options?.includeSummary) {
203
+ global.push(...asStringMessages(body.title));
204
+ global.push(...asStringMessages(body.message));
205
+ }
206
+
207
+ const errors = body.errors;
208
+ if (errors && typeof errors === "object" && !Array.isArray(errors)) {
209
+ for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
210
+ const mappedKey =
211
+ fieldMap?.[key] ?? (camelCase ? camelCaseField(key) : key);
212
+ const msgs = asStringMessages(value);
213
+ if (msgs.length) fields[mappedKey] = toFieldValue(msgs);
214
+ }
215
+ }
216
+
217
+ if (!Object.keys(fields).length && !global.length) return null;
218
+ return finalizeFormErrors(fields, global);
219
+ }
220
+
221
+ export type NodeFormErrorOptions = {
222
+ fieldMap?: Record<string, string>;
223
+ };
224
+
225
+ /**
226
+ * Common Node / Express shapes:
227
+ * - `{ errors: { email: ["Invalid"] } }`
228
+ * - `{ errors: [{ path: "email", msg: "Invalid" }] }` (express-validator)
229
+ * - `{ details: [{ message: "…", path: ["email"] }] }` (Joi)
230
+ */
231
+ export function parseNodeFormErrors(
232
+ error: unknown,
233
+ _ctx: ParseFormErrorContext,
234
+ options?: NodeFormErrorOptions,
235
+ ): FormValidationErrors | null {
236
+ const body = getErrorBody(error);
237
+ if (!body) return null;
238
+
239
+ const fields: Record<string, string | string[]> = {};
240
+ const global: string[] = [];
241
+ const fieldMap = options?.fieldMap;
242
+
243
+ const errList = body.errors;
244
+ if (Array.isArray(errList)) {
245
+ for (const item of errList) {
246
+ if (!item || typeof item !== "object") continue;
247
+ const row = item as Record<string, unknown>;
248
+ const path =
249
+ (typeof row.path === "string" && row.path) ||
250
+ (typeof row.param === "string" && row.param) ||
251
+ (typeof row.field === "string" && row.field);
252
+ const msg =
253
+ asStringMessages(row.msg)[0] ?? asStringMessages(row.message)[0];
254
+ if (!msg) continue;
255
+ if (path) {
256
+ const key = fieldMap?.[path] ?? path;
257
+ mergeFieldMessage(fields, key, msg);
258
+ } else {
259
+ global.push(msg);
260
+ }
261
+ }
262
+ } else if (errList && typeof errList === "object") {
263
+ for (const [key, value] of Object.entries(errList as Record<string, unknown>)) {
264
+ const mappedKey = fieldMap?.[key] ?? key;
265
+ const msgs = asStringMessages(value);
266
+ if (msgs.length) fields[mappedKey] = toFieldValue(msgs);
267
+ }
268
+ }
269
+
270
+ const details = body.details;
271
+ if (Array.isArray(details)) {
272
+ for (const item of details) {
273
+ if (!item || typeof item !== "object") continue;
274
+ const row = item as Record<string, unknown>;
275
+ const pathParts = Array.isArray(row.path) ? row.path : [];
276
+ const path = pathParts.map((p) => String(p)).join(".");
277
+ const msg = asStringMessages(row.message)[0];
278
+ if (!msg) continue;
279
+ if (path) {
280
+ const key = fieldMap?.[path] ?? path;
281
+ fields[key] = msg;
282
+ } else {
283
+ global.push(msg);
284
+ }
285
+ }
286
+ }
287
+
288
+ global.push(...asStringMessages(body.error));
289
+
290
+ if (!Object.keys(fields).length && !global.length) return null;
291
+ return finalizeFormErrors(fields, global);
292
+ }
293
+
294
+ function mergeFieldMessage(
295
+ fields: Record<string, string | string[]>,
296
+ key: string,
297
+ msg: string,
298
+ ) {
299
+ const existing = fields[key];
300
+ if (!existing) {
301
+ fields[key] = msg;
302
+ return;
303
+ }
304
+ fields[key] = Array.isArray(existing) ? [...existing, msg] : [existing, msg];
305
+ }
306
+
307
+ function camelCaseField(key: string): string {
308
+ if (!key) return key;
309
+ return key.charAt(0).toLowerCase() + key.slice(1);
310
+ }
311
+
@@ -7,6 +7,7 @@ import type {
7
7
  DeleteResult,
8
8
  GetListParams,
9
9
  GetListResult,
10
+ GetOneParams,
10
11
  GetOneResult,
11
12
  Identifier,
12
13
  ParseFormError,
@@ -27,8 +28,8 @@ export type ResourceHandlers<
27
28
  RecordType extends Record<string, unknown> = Record<string, unknown>,
28
29
  > = {
29
30
  getList: (params: GetListParams) => Promise<GetListResult<RecordType>>;
30
- getOne: (id: Identifier) => Promise<GetOneResult<RecordType>>;
31
- create: (data: Partial<RecordType>) => Promise<CreateResult<RecordType>>;
31
+ getOne: (id: Identifier, params?: GetOneParams) => Promise<GetOneResult<RecordType>>;
32
+ create: (data: Partial<RecordType> | FormData) => Promise<CreateResult<RecordType>>;
32
33
  update: (
33
34
  params: UpdateParams<RecordType>,
34
35
  ) => Promise<UpdateResult<RecordType>>;
@@ -95,11 +96,11 @@ export function combineResourceHandlers(
95
96
  assertPermission(can, permissions, "list");
96
97
  return h.getList(params);
97
98
  },
98
- async getOne(resource, id) {
99
+ async getOne(resource, id, params) {
99
100
  const { handlers: h, permissions } = resolve(resource);
100
101
  guard?.(resource, "read");
101
102
  assertPermission(can, permissions, "read");
102
- return h.getOne(id);
103
+ return h.getOne(id, params);
103
104
  },
104
105
  async create(resource, data) {
105
106
  const { handlers: h, permissions } = resolve(resource);