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,32 +1,19 @@
1
1
  import { ArrowLeftOutlined } from "@ant-design/icons";
2
2
  import { App, Button, Card, Form, Space, Spin, Typography, theme } from "antd";
3
- import {
4
- FormProvider,
5
- useForm,
6
- type DefaultValues,
7
- type FieldValues,
8
- } from "react-hook-form";
9
- import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
3
+ import { FormProvider, useForm, type DefaultValues, type FieldValues } from "react-hook-form";
4
+ import { useEffect, useRef, type ReactNode } from "react";
10
5
  import { Link, useNavigate, useParams } from "react-router-dom";
11
6
  import { useDataProvider } from "../context/DataProvider";
12
7
  import { usePermissions } from "../context/PermissionsProvider";
13
8
  import type { ResourcePermissions } from "../permissions/resourcePermissions";
14
9
  import { checkResourcePermission } from "../permissions/resourcePermissions";
15
10
  import { FormMetaProvider } from "./context/FormContext";
16
- import { FormFieldsProvider } from "./context/FormFieldsContext";
17
- import { pickBySources } from "./utils/pickBySources";
11
+ import { PayloadFieldsProvider } from "./context/PayloadFieldsContext";
18
12
  import {
19
- loadInlineRows,
20
- saveInlineRows,
21
- type InlineFormSetProps,
22
- } from "./InlineFormSet";
23
- import { inlineArrayName } from "./utils/inlineArrayName";
24
- import { parseAndApplyFormErrors } from "./utils/formErrors";
25
-
26
- export type ResourceFormInlineConfig = Pick<
27
- InlineFormSetProps,
28
- "resource" | "foreignKey" | "name"
29
- >;
13
+ InlineFieldsRegistryProvider,
14
+ type InlineFieldRegistration,
15
+ } from "./context/InlineFieldsRegistry";
16
+ import { useFormRecordLoad, useFormRecordSave } from "./utils/useFormRecord";
30
17
 
31
18
  export type ResourceFormProps<T extends FieldValues> = {
32
19
  resource: string;
@@ -36,15 +23,16 @@ export type ResourceFormProps<T extends FieldValues> = {
36
23
  defaultValues?: Partial<T>;
37
24
  onSaved?: (record: T) => void;
38
25
  stayOnPage?: boolean;
39
- inlines?: ResourceFormInlineConfig[];
40
26
  /** Permission strings for create vs edit. Omit to allow all (demos only). */
41
27
  permissions?: Pick<ResourcePermissions, "add" | "change">;
42
28
  };
43
29
 
44
- function resolveInlineArrayName(config: ResourceFormInlineConfig) {
45
- return inlineArrayName(config.resource, config.name);
46
- }
47
-
30
+ /**
31
+ * Create/edit page — standard react-hook-form under the hood.
32
+ *
33
+ * Load: one `getOne` → `form.reset(record)` (nested inline arrays included).
34
+ * Save: one `create` or `update` with parent fields + nested inline rows.
35
+ */
48
36
  export function ResourceForm<T extends FieldValues & { id?: unknown }>({
49
37
  resource,
50
38
  title,
@@ -53,7 +41,6 @@ export function ResourceForm<T extends FieldValues & { id?: unknown }>({
53
41
  defaultValues,
54
42
  onSaved,
55
43
  stayOnPage,
56
- inlines,
57
44
  permissions,
58
45
  }: ResourceFormProps<T>) {
59
46
  const { id } = useParams();
@@ -63,83 +50,37 @@ export function ResourceForm<T extends FieldValues & { id?: unknown }>({
63
50
  const navigate = useNavigate();
64
51
  const { message } = App.useApp();
65
52
  const { token } = theme.useToken();
66
- const [loading, setLoading] = useState(!isNew);
67
- const [formVersion, setFormVersion] = useState(0);
68
- const [existingInlineIds, setExistingInlineIds] = useState<
69
- Record<string, (string | number)[]>
70
- >({});
53
+ const payloadFieldsRef = useRef(new Set<string>());
54
+ const inlineRegistryRef = useRef(new Map<string, InlineFieldRegistration>());
71
55
 
72
56
  const form = useForm<T>({
73
57
  defaultValues: defaultValues as DefaultValues<T>,
74
58
  });
75
- const fieldSourcesRef = useRef(new Set<string>());
76
-
77
- const loadInlines = useCallback(
78
- async (parentId: string | number) => {
79
- if (!inlines?.length) return;
80
- const idsMap: Record<string, (string | number)[]> = {};
81
- for (const cfg of inlines) {
82
- const arrayName = resolveInlineArrayName(cfg);
83
- const { rows, ids } = await loadInlineRows(
84
- dp,
85
- cfg.resource,
86
- cfg.foreignKey,
87
- parentId,
88
- );
89
- form.setValue(
90
- arrayName as never,
91
- rows as never,
92
- );
93
- idsMap[arrayName] = ids;
94
- }
95
- setExistingInlineIds(idsMap);
96
- },
97
- [dp, inlines, form],
98
- );
99
59
 
100
- const load = useCallback(async () => {
101
- if (isNew || !id) {
102
- if (defaultValues) {
103
- form.reset({ ...defaultValues } as DefaultValues<T>);
104
- }
105
- setLoading(false);
106
- return;
107
- }
108
- setLoading(true);
109
- try {
110
- const res = await dp.getOne(resource, id);
111
- const record = res.data as DefaultValues<T>;
112
- if (inlines?.length) {
113
- const merged = { ...record } as DefaultValues<T>;
114
- const idsMap: Record<string, (string | number)[]> = {};
115
- for (const cfg of inlines) {
116
- const arrayName = resolveInlineArrayName(cfg);
117
- const { rows, ids } = await loadInlineRows(
118
- dp,
119
- cfg.resource,
120
- cfg.foreignKey,
121
- id,
122
- );
123
- (merged as Record<string, unknown>)[arrayName] = rows;
124
- idsMap[arrayName] = ids;
125
- }
126
- form.reset(merged);
127
- setExistingInlineIds(idsMap);
128
- setFormVersion((v) => v + 1);
129
- } else {
130
- form.reset(record);
131
- setFormVersion((v) => v + 1);
132
- }
133
- } catch (e) {
134
- message.error(e instanceof Error ? e.message : "Load failed");
135
- } finally {
136
- setLoading(false);
137
- }
138
- }, [dp, resource, id, isNew, form, message, defaultValues, loadInlines]);
60
+ const { loading, formVersion } = useFormRecordLoad({
61
+ dp,
62
+ resource,
63
+ id,
64
+ isNew,
65
+ form,
66
+ message,
67
+ defaultValues,
68
+ });
139
69
 
140
- useEffect(() => {
141
- void load();
142
- }, [load]);
70
+ const onSubmit = useFormRecordSave({
71
+ dp,
72
+ resource,
73
+ id,
74
+ isNew,
75
+ form,
76
+ message,
77
+ navigate,
78
+ listPath,
79
+ payloadFieldsRef,
80
+ inlineRegistryRef,
81
+ onSaved,
82
+ stayOnPage,
83
+ });
143
84
 
144
85
  useEffect(() => {
145
86
  if (!permissions) return;
@@ -153,74 +94,6 @@ export function ResourceForm<T extends FieldValues & { id?: unknown }>({
153
94
  ? checkResourcePermission(can, permissions, isNew ? "add" : "change")
154
95
  : true;
155
96
 
156
- async function onSubmit(values: T) {
157
- try {
158
- const raw = values as Record<string, unknown>;
159
- let payload = pickBySources(raw, Array.from(fieldSourcesRef.current));
160
- if (inlines?.length) {
161
- for (const cfg of inlines) {
162
- delete payload[resolveInlineArrayName(cfg)];
163
- }
164
- }
165
-
166
- let saved: T;
167
- if (isNew) {
168
- const res = await dp.create(resource, payload);
169
- saved = res.data as T;
170
- message.success("Created");
171
- } else if (id) {
172
- const res = await dp.update(resource, { id, data: payload });
173
- saved = res.data as T;
174
- message.success("Updated");
175
- } else {
176
- return;
177
- }
178
-
179
- const parentId = saved.id as string | number;
180
- if (inlines?.length && parentId != null) {
181
- for (const cfg of inlines) {
182
- const arrayName = resolveInlineArrayName(cfg);
183
- const rows =
184
- ((form.getValues(arrayName as never) as unknown) as
185
- Record<string, unknown>[] | undefined) ?? [];
186
- const inlineOk = await saveInlineRows(dp, {
187
- resource: cfg.resource,
188
- foreignKey: cfg.foreignKey,
189
- parentId,
190
- rows,
191
- existingIds: existingInlineIds[arrayName] ?? [],
192
- onRowError: (e, index) => {
193
- const row = rows[index];
194
- const rowId = row?.id as string | number | undefined;
195
- return parseAndApplyFormErrors(dp, form, message, e, {
196
- resource: cfg.resource,
197
- mutation:
198
- rowId != null &&
199
- (existingInlineIds[arrayName] ?? []).some((id) => id === rowId)
200
- ? "update"
201
- : "create",
202
- inlineArrayName: arrayName,
203
- rowIndex: index,
204
- });
205
- },
206
- });
207
- if (!inlineOk) return;
208
- }
209
- }
210
-
211
- onSaved?.(saved);
212
- if (!stayOnPage) navigate(listPath);
213
- } catch (e) {
214
- const handled = parseAndApplyFormErrors(dp, form, message, e, {
215
- resource,
216
- mutation: isNew ? "create" : "update",
217
- });
218
- if (!handled) {
219
- message.error(e instanceof Error ? e.message : "Save failed");
220
- }
221
- }
222
- }
223
-
224
97
  return (
225
98
  <Card
226
99
  title={
@@ -235,46 +108,52 @@ export function ResourceForm<T extends FieldValues & { id?: unknown }>({
235
108
  }
236
109
  >
237
110
  <FormMetaProvider resource={resource} isNew={isNew}>
238
- <FormFieldsProvider sourcesRef={fieldSourcesRef}>
239
- <div style={{ position: "relative" }}>
240
- {loading ? (
241
- <div
242
- style={{
243
- position: "absolute",
244
- inset: 0,
245
- display: "flex",
246
- alignItems: "center",
247
- justifyContent: "center",
248
- zIndex: 1,
249
- }}
250
- >
251
- <Spin />
111
+ <PayloadFieldsProvider fieldsRef={payloadFieldsRef}>
112
+ <InlineFieldsRegistryProvider registryRef={inlineRegistryRef}>
113
+ <div style={{ position: "relative" }}>
114
+ {loading ? (
115
+ <div
116
+ style={{
117
+ position: "absolute",
118
+ inset: 0,
119
+ display: "flex",
120
+ alignItems: "center",
121
+ justifyContent: "center",
122
+ zIndex: 1,
123
+ }}
124
+ >
125
+ <Spin />
126
+ </div>
127
+ ) : null}
128
+ <FormProvider {...form} key={formVersion}>
129
+ <Form
130
+ layout="vertical"
131
+ onFinish={() => void form.handleSubmit(onSubmit)()}
132
+ style={{
133
+ opacity: loading ? 0.4 : 1,
134
+ pointerEvents: loading ? "none" : undefined,
135
+ }}
136
+ >
137
+ {children}
138
+ <Form.Item style={{ marginTop: 16 }}>
139
+ <Space>
140
+ <Button
141
+ type="primary"
142
+ htmlType="submit"
143
+ disabled={loading || !canSave}
144
+ >
145
+ Save
146
+ </Button>
147
+ <Link to={listPath}>
148
+ <Button disabled={loading}>Cancel</Button>
149
+ </Link>
150
+ </Space>
151
+ </Form.Item>
152
+ </Form>
153
+ </FormProvider>
252
154
  </div>
253
- ) : null}
254
- <FormProvider {...form} key={formVersion}>
255
- <Form
256
- layout="vertical"
257
- onFinish={() => void form.handleSubmit(onSubmit)()}
258
- style={{
259
- opacity: loading ? 0.4 : 1,
260
- pointerEvents: loading ? "none" : undefined,
261
- }}
262
- >
263
- {children}
264
- <Form.Item style={{ marginTop: 16 }}>
265
- <Space>
266
- <Button type="primary" htmlType="submit" disabled={loading || !canSave}>
267
- Save
268
- </Button>
269
- <Link to={listPath}>
270
- <Button disabled={loading}>Cancel</Button>
271
- </Link>
272
- </Space>
273
- </Form.Item>
274
- </Form>
275
- </FormProvider>
276
- </div>
277
- </FormFieldsProvider>
155
+ </InlineFieldsRegistryProvider>
156
+ </PayloadFieldsProvider>
278
157
  </FormMetaProvider>
279
158
  </Card>
280
159
  );
@@ -5,12 +5,14 @@ import {
5
5
  type DefaultValues,
6
6
  type FieldValues,
7
7
  } from "react-hook-form";
8
- import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
8
+ import { useCallback, useRef, useState, type ReactNode } from "react";
9
9
  import { useDataProvider } from "../context/DataProvider";
10
10
  import { FormMetaProvider } from "./context/FormContext";
11
- import { FormFieldsProvider } from "./context/FormFieldsContext";
12
- import { pickBySources } from "./utils/pickBySources";
13
- import { parseAndApplyFormErrors } from "./utils/formErrors";
11
+ import { PayloadFieldsProvider } from "./context/PayloadFieldsContext";
12
+ import { buildResourceFormSubmitBody } from "./utils/buildResourceFormSubmitBody";
13
+ import { applyApiErrorsToForm } from "./utils/formErrors";
14
+ import { useAbortableEffect } from "./utils/useAbortableEffect";
15
+ import { isAbortError } from "../data/abortError";
14
16
 
15
17
  export type ResourceFormModalProps = {
16
18
  resource: string;
@@ -34,45 +36,55 @@ export function ResourceFormModal({
34
36
  const [loading, setLoading] = useState(!isNew);
35
37
 
36
38
  const form = useForm<FieldValues>();
37
- const fieldSourcesRef = useRef(new Set<string>());
39
+ const payloadFieldsRef = useRef(new Set<string>());
38
40
 
39
- const load = useCallback(async () => {
40
- if (isNew || !editId) {
41
- form.reset({} as DefaultValues<FieldValues>);
42
- setLoading(false);
43
- return;
44
- }
45
- setLoading(true);
46
- try {
47
- const res = await dp.getOne(resource, editId);
48
- form.reset(res.data as DefaultValues<FieldValues>);
49
- } catch (e) {
50
- message.error(e instanceof Error ? e.message : "Load failed");
51
- } finally {
52
- setLoading(false);
53
- }
54
- }, [dp, resource, editId, isNew, form, message]);
41
+ const load = useCallback(
42
+ async (signal?: AbortSignal) => {
43
+ if (isNew || !editId) {
44
+ form.reset({} as DefaultValues<FieldValues>);
45
+ setLoading(false);
46
+ return;
47
+ }
48
+ setLoading(true);
49
+ try {
50
+ const res = await dp.getOne(resource, editId, { signal });
51
+ if (signal?.aborted) return;
52
+ form.reset(res.data as DefaultValues<FieldValues>);
53
+ } catch (e) {
54
+ if (!isAbortError(e)) {
55
+ message.error(e instanceof Error ? e.message : "Load failed");
56
+ }
57
+ } finally {
58
+ if (!signal?.aborted) setLoading(false);
59
+ }
60
+ },
61
+ [dp, resource, editId, isNew, form, message],
62
+ );
55
63
 
56
- useEffect(() => {
57
- if (open) void load();
58
- }, [open, load]);
64
+ useAbortableEffect(
65
+ (signal) => {
66
+ if (!open) return;
67
+ return load(signal);
68
+ },
69
+ [open, load],
70
+ );
59
71
 
60
72
  async function onSubmit(values: FieldValues) {
61
73
  try {
62
- const payload = pickBySources(
74
+ const body = buildResourceFormSubmitBody(
63
75
  values as Record<string, unknown>,
64
- Array.from(fieldSourcesRef.current),
76
+ Array.from(payloadFieldsRef.current),
65
77
  );
66
78
  if (isNew) {
67
- await dp.create(resource, payload);
79
+ await dp.create(resource, body);
68
80
  message.success("Created");
69
81
  } else if (editId) {
70
- await dp.update(resource, { id: editId, data: payload });
82
+ await dp.update(resource, { id: editId, data: body });
71
83
  message.success("Updated");
72
84
  }
73
85
  onClose();
74
86
  } catch (e) {
75
- const handled = parseAndApplyFormErrors(dp, form, message, e, {
87
+ const handled = await applyApiErrorsToForm(dp, form, message, e, {
76
88
  resource,
77
89
  mutation: isNew ? "create" : "update",
78
90
  });
@@ -98,22 +110,22 @@ export function ResourceFormModal({
98
110
  <Spin />
99
111
  ) : (
100
112
  <FormMetaProvider resource={resource} isNew={isNew}>
101
- <FormFieldsProvider sourcesRef={fieldSourcesRef}>
102
- <FormProvider {...form}>
103
- <Form
104
- layout="vertical"
105
- onFinish={() => void form.handleSubmit(onSubmit)()}
106
- >
107
- {children}
108
- <Form.Item style={{ marginTop: 16, marginBottom: 0 }}>
109
- <Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
110
- Save
111
- </Button>
112
- <Button onClick={onClose}>Cancel</Button>
113
- </Form.Item>
114
- </Form>
115
- </FormProvider>
116
- </FormFieldsProvider>
113
+ <PayloadFieldsProvider fieldsRef={payloadFieldsRef}>
114
+ <FormProvider {...form}>
115
+ <Form
116
+ layout="vertical"
117
+ onFinish={() => void form.handleSubmit(onSubmit)()}
118
+ >
119
+ {children}
120
+ <Form.Item style={{ marginTop: 16, marginBottom: 0 }}>
121
+ <Button type="primary" htmlType="submit" style={{ marginRight: 8 }}>
122
+ Save
123
+ </Button>
124
+ <Button onClick={onClose}>Cancel</Button>
125
+ </Form.Item>
126
+ </Form>
127
+ </FormProvider>
128
+ </PayloadFieldsProvider>
117
129
  </FormMetaProvider>
118
130
  )}
119
131
  </Modal>
@@ -9,7 +9,6 @@ import {
9
9
  createContext,
10
10
  useCallback,
11
11
  useContext,
12
- useEffect,
13
12
  useMemo,
14
13
  useState,
15
14
  type Key,
@@ -32,6 +31,8 @@ import type {
32
31
  ResourceListRowActionsHelpers,
33
32
  } from "./types";
34
33
  import { useListQueryState } from "./utils/useListQueryState";
34
+ import { useAbortableEffect } from "./utils/useAbortableEffect";
35
+ import { isAbortError } from "../data/abortError";
35
36
 
36
37
  type ResourceListContextValue = {
37
38
  filterValues: Record<string, unknown>;
@@ -263,32 +264,40 @@ function ResourceListTable<T extends Record<string, unknown>>({
263
264
  [queryActions, queryState.sort.length],
264
265
  );
265
266
 
266
- const load = useCallback(async () => {
267
- setLoading(true);
268
- try {
269
- const sort =
270
- queryState.sort.length === 0
271
- ? undefined
272
- : queryState.sort.length === 1
273
- ? queryState.sort[0]
274
- : queryState.sort;
275
- const res = await dp.getList(resource, {
276
- pagination: { page: queryState.page, perPage: queryState.perPage },
277
- sort,
278
- filter: queryState.filter,
279
- });
280
- setData(res.data as T[]);
281
- setTotal(res.total);
282
- } catch (e) {
283
- message.error(e instanceof Error ? e.message : "Load failed");
284
- } finally {
285
- setLoading(false);
286
- }
287
- }, [dp, resource, queryState, message]);
267
+ const listParams = useMemo(() => {
268
+ const sort =
269
+ queryState.sort.length === 0
270
+ ? undefined
271
+ : queryState.sort.length === 1
272
+ ? queryState.sort[0]
273
+ : queryState.sort;
274
+ return {
275
+ pagination: { page: queryState.page, perPage: queryState.perPage },
276
+ sort,
277
+ filter: queryState.filter,
278
+ };
279
+ }, [queryState]);
280
+
281
+ const load = useCallback(
282
+ async (signal?: AbortSignal) => {
283
+ setLoading(true);
284
+ try {
285
+ const res = await dp.getList(resource, { ...listParams, signal });
286
+ if (signal?.aborted) return;
287
+ setData(res.data as T[]);
288
+ setTotal(res.total);
289
+ } catch (e) {
290
+ if (!isAbortError(e)) {
291
+ message.error(e instanceof Error ? e.message : "Load failed");
292
+ }
293
+ } finally {
294
+ if (!signal?.aborted) setLoading(false);
295
+ }
296
+ },
297
+ [dp, resource, listParams, message],
298
+ );
288
299
 
289
- useEffect(() => {
290
- void load();
291
- }, [load]);
300
+ useAbortableEffect((signal) => load(signal), [load]);
292
301
 
293
302
  const bulkActionHelpers = useMemo(
294
303
  (): ResourceListBulkActionHelpers => ({
@@ -517,6 +526,7 @@ function ResourceListTable<T extends Record<string, unknown>>({
517
526
  loading={loading}
518
527
  columns={tableColumns}
519
528
  dataSource={data}
529
+ scroll={{ x: "max-content" }}
520
530
  rowSelection={
521
531
  showBulkActions
522
532
  ? {
@@ -0,0 +1,58 @@
1
+ import { useMemo, type CSSProperties } from "react";
2
+ import type { BaseSourceProps } from "../types";
3
+ import { useRegisterColumn } from "../context/ListContext";
4
+
5
+ export type ImageColumnProps = BaseSourceProps & {
6
+ sortable?: boolean;
7
+ /** Thumbnail width in pixels. */
8
+ width?: number;
9
+ /** Thumbnail height in pixels. */
10
+ height?: number;
11
+ objectFit?: CSSProperties["objectFit"];
12
+ borderRadius?: number;
13
+ alt?: string;
14
+ };
15
+
16
+ export function ImageColumn({
17
+ source,
18
+ label,
19
+ sortable = false,
20
+ width = 40,
21
+ height = 40,
22
+ objectFit = "cover",
23
+ borderRadius = 4,
24
+ alt = "",
25
+ }: ImageColumnProps) {
26
+ const def = useMemo(
27
+ () => ({
28
+ key: source,
29
+ source,
30
+ label,
31
+ sortable,
32
+ buildColumn: () => ({
33
+ title: label ?? source,
34
+ dataIndex: source,
35
+ key: source,
36
+ sorter: sortable ? true : undefined,
37
+ render: (value: unknown) => {
38
+ if (value == null || value === "") return null;
39
+ return (
40
+ <img
41
+ src={String(value)}
42
+ alt={alt}
43
+ style={{
44
+ width,
45
+ height,
46
+ objectFit,
47
+ borderRadius,
48
+ }}
49
+ />
50
+ );
51
+ },
52
+ }),
53
+ }),
54
+ [source, label, sortable, width, height, objectFit, borderRadius, alt],
55
+ );
56
+ useRegisterColumn(def);
57
+ return null;
58
+ }
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import type { BaseSourceProps } from "../types";
3
- import { getByPath } from "../utils/getByPath";
3
+ import { getFormValue } from "../utils/getFormValue";
4
4
  import { useRegisterColumn } from "../context/ListContext";
5
5
 
6
6
  export type TextColumnProps = BaseSourceProps & {
@@ -33,6 +33,6 @@ export function renderDisplayValue(
33
33
  display?: string | ((record: Record<string, unknown>) => unknown),
34
34
  ): unknown {
35
35
  if (typeof display === "function") return display(record);
36
- if (display) return getByPath(record, display);
36
+ if (display) return getFormValue(record, display);
37
37
  return record[source];
38
38
  }