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
@@ -1,78 +1,78 @@
1
- import { applyInMemoryListParams, getById, rowIdMatches } from "./inMemoryList";
2
- import type {
3
- CreateResult,
4
- DeleteResult,
5
- GetListParams,
6
- GetListResult,
7
- GetOneResult,
8
- UpdateResult,
9
- } from "./dataProviderTypes";
10
- import type { ResourceHandlers } from "./resourceHandlers";
11
-
12
- type Row = Record<string, unknown>;
13
-
14
- export type MemoryResourceHandlersConfig<
15
- T extends Record<string, unknown> & { id: string | number },
16
- > = {
17
- getRows: () => T[];
18
- nextId: () => string | number;
19
- /** Defaults to `{ ...data, id }` from form field sources. */
20
- mapCreate?: (data: Row, id: string | number) => T;
21
- /** Defaults to spread patch over current row. */
22
- applyUpdate?: (current: T, patch: Row) => T;
23
- scopeList?: (rows: T[], params: GetListParams) => T[];
24
- afterDelete?: (removed: T) => void;
25
- };
26
-
27
- /**
28
- * Generic in-memory CRUD for demos and tests. Override hooks for entity-specific rules.
29
- */
30
- export function createMemoryResourceHandlers<
31
- T extends Record<string, unknown> & { id: string | number },
32
- >(config: MemoryResourceHandlersConfig<T>): ResourceHandlers<T> {
33
- const asRows = (xs: T[]) => xs as unknown as Row[];
34
-
35
- const mapCreate =
36
- config.mapCreate ??
37
- ((data: Row, id: string | number) => ({ ...data, id }) as T);
38
-
39
- const applyUpdate =
40
- config.applyUpdate ??
41
- ((current: T, patch: Row) => ({ ...current, ...patch, id: current.id }) as T);
42
-
43
- return {
44
- async getList(params): Promise<GetListResult<T>> {
45
- const base = config.scopeList
46
- ? config.scopeList(config.getRows(), params)
47
- : config.getRows();
48
- return applyInMemoryListParams(asRows(base), params) as GetListResult<T>;
49
- },
50
-
51
- async getOne(id, _params?): Promise<GetOneResult<T>> {
52
- return { data: getById(config.getRows(), id) as T };
53
- },
54
-
55
- async create(data): Promise<CreateResult<T>> {
56
- const row = mapCreate(data as Row, config.nextId());
57
- config.getRows().push(row);
58
- return { data: row };
59
- },
60
-
61
- async update({ id, data }): Promise<UpdateResult<T>> {
62
- const current = getById(config.getRows(), id);
63
- const patch = data as Row;
64
- const next = applyUpdate(current, patch);
65
- Object.assign(current, next);
66
- return { data: current };
67
- },
68
-
69
- async delete(id): Promise<DeleteResult<T>> {
70
- const rows = config.getRows();
71
- const idx = rows.findIndex((r) => rowIdMatches(r.id, id));
72
- if (idx < 0) return { data: null };
73
- const [removed] = rows.splice(idx, 1);
74
- config.afterDelete?.(removed);
75
- return { data: removed };
76
- },
77
- };
78
- }
1
+ import { applyInMemoryListParams, getById, rowIdMatches } from "./inMemoryList";
2
+ import type {
3
+ CreateResult,
4
+ DeleteResult,
5
+ GetListParams,
6
+ GetListResult,
7
+ GetOneResult,
8
+ UpdateResult,
9
+ } from "./dataProviderTypes";
10
+ import type { ResourceHandlers } from "./resourceHandlers";
11
+
12
+ type Row = Record<string, unknown>;
13
+
14
+ export type MemoryResourceHandlersConfig<
15
+ T extends Record<string, unknown> & { id: string | number },
16
+ > = {
17
+ getRows: () => T[];
18
+ nextId: () => string | number;
19
+ /** Defaults to `{ ...data, id }` from form field sources. */
20
+ mapCreate?: (data: Row, id: string | number) => T;
21
+ /** Defaults to spread patch over current row. */
22
+ applyUpdate?: (current: T, patch: Row) => T;
23
+ scopeList?: (rows: T[], params: GetListParams) => T[];
24
+ afterDelete?: (removed: T) => void;
25
+ };
26
+
27
+ /**
28
+ * Generic in-memory CRUD for demos and tests. Override hooks for entity-specific rules.
29
+ */
30
+ export function createMemoryResourceHandlers<
31
+ T extends Record<string, unknown> & { id: string | number },
32
+ >(config: MemoryResourceHandlersConfig<T>): ResourceHandlers<T> {
33
+ const asRows = (xs: T[]) => xs as unknown as Row[];
34
+
35
+ const mapCreate =
36
+ config.mapCreate ??
37
+ ((data: Row, id: string | number) => ({ ...data, id }) as T);
38
+
39
+ const applyUpdate =
40
+ config.applyUpdate ??
41
+ ((current: T, patch: Row) => ({ ...current, ...patch, id: current.id }) as T);
42
+
43
+ return {
44
+ async getList(params): Promise<GetListResult<T>> {
45
+ const base = config.scopeList
46
+ ? config.scopeList(config.getRows(), params)
47
+ : config.getRows();
48
+ return applyInMemoryListParams(asRows(base), params) as GetListResult<T>;
49
+ },
50
+
51
+ async getOne(id, _params?): Promise<GetOneResult<T>> {
52
+ return { data: getById(config.getRows(), id) as T };
53
+ },
54
+
55
+ async create(data): Promise<CreateResult<T>> {
56
+ const row = mapCreate(data as Row, config.nextId());
57
+ config.getRows().push(row);
58
+ return { data: row };
59
+ },
60
+
61
+ async update({ id, data }): Promise<UpdateResult<T>> {
62
+ const current = getById(config.getRows(), id);
63
+ const patch = data as Row;
64
+ const next = applyUpdate(current, patch);
65
+ Object.assign(current, next);
66
+ return { data: current };
67
+ },
68
+
69
+ async delete(id): Promise<DeleteResult<T>> {
70
+ const rows = config.getRows();
71
+ const idx = rows.findIndex((r) => rowIdMatches(r.id, id));
72
+ if (idx < 0) return { data: null };
73
+ const [removed] = rows.splice(idx, 1);
74
+ config.afterDelete?.(removed);
75
+ return { data: removed };
76
+ },
77
+ };
78
+ }
@@ -1,57 +1,63 @@
1
- import type { GetListParams, GetOneParams, Identifier } from "./dataProviderTypes";
2
- import type { ResourceHandlers } from "./resourceHandlers";
3
-
4
- export type RestResourceHandlersConfig<
5
- T extends Record<string, unknown> = Record<string, unknown>,
6
- > = {
7
- list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
8
- retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
9
- create: (data: Record<string, unknown>) => Promise<T>;
10
- update: (id: Identifier, data: Record<string, unknown>) => Promise<T>;
11
- destroy: (id: Identifier) => Promise<void>;
12
- /** When API create body differs from form `source` paths. */
13
- transformCreate?: (data: Record<string, unknown>) => unknown;
14
- /** When API update body differs from form `source` paths. */
15
- transformUpdate?: (data: Record<string, unknown>) => unknown;
16
- };
17
-
18
- /**
19
- * Thin CRUD glue for REST backends. Form payload comes from field `source` paths;
20
- * wire your fetch or API client in the five functions below.
21
- */
22
- export function createRestResourceHandlers<
23
- T extends Record<string, unknown> = Record<string, unknown>,
24
- >(config: RestResourceHandlersConfig<T>): ResourceHandlers<T> {
25
- return {
26
- async getList(params) {
27
- return config.list(params);
28
- },
29
-
30
- async getOne(id, params) {
31
- return { data: await config.retrieve(id, params) };
32
- },
33
-
34
- async create(data) {
35
- const body = config.transformCreate
36
- ? config.transformCreate(data as Record<string, unknown>)
37
- : data;
38
- return {
39
- data: await config.create(body as Record<string, unknown>),
40
- };
41
- },
42
-
43
- async update({ id, data }) {
44
- const body = config.transformUpdate
45
- ? config.transformUpdate(data as Record<string, unknown>)
46
- : data;
47
- return {
48
- data: await config.update(id, body as Record<string, unknown>),
49
- };
50
- },
51
-
52
- async delete(id) {
53
- await config.destroy(id);
54
- return { data: null };
55
- },
56
- };
57
- }
1
+ import type { GetListParams, GetOneParams, Identifier, FormMutationBody } from "./dataProviderTypes";
2
+ import type { ResourceHandlers } from "./resourceHandlers";
3
+
4
+ export type RestResourceHandlersConfig<
5
+ T extends Record<string, unknown> = Record<string, unknown>,
6
+ > = {
7
+ list: (params: GetListParams) => Promise<{ data: T[]; total: number }>;
8
+ retrieve: (id: Identifier, params?: GetOneParams) => Promise<T>;
9
+ create: (data: FormMutationBody) => Promise<T>;
10
+ update: (id: Identifier, data: FormMutationBody) => Promise<T>;
11
+ destroy: (id: Identifier) => Promise<void>;
12
+ /** When API create body differs from form `source` paths. Not applied when body is already `FormData`. */
13
+ transformCreate?: (data: Record<string, unknown>) => unknown;
14
+ /** When API update body differs from form `source` paths. Not applied when body is already `FormData`. */
15
+ transformUpdate?: (data: Record<string, unknown>) => unknown;
16
+ };
17
+
18
+ /**
19
+ * Thin CRUD glue for REST backends. Form payload comes from field `source` paths;
20
+ * wire your fetch or API client in the five functions below.
21
+ */
22
+ export function createRestResourceHandlers<
23
+ T extends Record<string, unknown> = Record<string, unknown>,
24
+ >(config: RestResourceHandlersConfig<T>): ResourceHandlers<T> {
25
+ return {
26
+ async getList(params) {
27
+ return config.list(params);
28
+ },
29
+
30
+ async getOne(id, params) {
31
+ return { data: await config.retrieve(id, params) };
32
+ },
33
+
34
+ async create(data) {
35
+ const body =
36
+ data instanceof FormData
37
+ ? data
38
+ : config.transformCreate
39
+ ? config.transformCreate(data as Record<string, unknown>)
40
+ : data;
41
+ return {
42
+ data: await config.create(body as FormMutationBody),
43
+ };
44
+ },
45
+
46
+ async update({ id, data }) {
47
+ const body =
48
+ data instanceof FormData
49
+ ? data
50
+ : config.transformUpdate
51
+ ? config.transformUpdate(data as Record<string, unknown>)
52
+ : data;
53
+ return {
54
+ data: await config.update(id, body as FormMutationBody),
55
+ };
56
+ },
57
+
58
+ async delete(id) {
59
+ await config.destroy(id);
60
+ return { data: null };
61
+ },
62
+ };
63
+ }
@@ -43,7 +43,7 @@ export type CreateResult<RecordType extends Record<string, unknown>> = {
43
43
 
44
44
  export type UpdateParams<RecordType extends Record<string, unknown>> = {
45
45
  id: Identifier;
46
- data: Partial<RecordType>;
46
+ data: Partial<RecordType> | FormData;
47
47
  };
48
48
 
49
49
  export type UpdateResult<RecordType extends Record<string, unknown>> = {
@@ -54,14 +54,16 @@ export type DeleteResult<RecordType extends Record<string, unknown>> = {
54
54
  data: RecordType | null;
55
55
  };
56
56
 
57
+ /** Save body from forms — plain JSON object or multipart `FormData` when uploads are present. */
58
+ export type FormMutationBody = Record<string, unknown> | FormData;
59
+
57
60
  export type FormMutation = "create" | "update";
58
61
 
59
62
  export type ParseFormErrorContext = {
60
63
  resource: string;
61
64
  mutation: FormMutation;
62
- /** Set when saving an inline row — prefix field paths for that row. */
63
- inlineArrayName?: string;
64
- rowIndex?: number;
65
+ /** Top-level inline field-array paths on the form, e.g. `["lines"]`. */
66
+ inlineFieldPaths?: string[];
65
67
  };
66
68
 
67
69
  /** Field paths match react-hook-form `name` (form `source` or inline `arrayName.index.source`). */
@@ -94,7 +96,7 @@ export type DataProvider<
94
96
  ) => Promise<GetOneResult<RecordType>>;
95
97
  create: (
96
98
  resource: string,
97
- data: Partial<RecordType>,
99
+ data: Partial<RecordType> | FormData,
98
100
  ) => Promise<CreateResult<RecordType>>;
99
101
  update: (
100
102
  resource: string,
@@ -1,108 +1,228 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { pickBySources } from "../crud/utils/pickBySources";
3
- import { createRestResourceHandlers } from "./createRestResourceHandlers";
4
- import {
5
- toDjangoRestOrdering,
6
- toJsonApiSort,
7
- toODataOrderBy,
8
- } from "./sortHelpers";
9
-
10
- describe("pickBySources", () => {
11
- it("picks flat fields", () => {
12
- const values = {
13
- username: "jane",
14
- email: "jane@example.com",
15
- tenants: [{ id: 1 }],
16
- };
17
- expect(pickBySources(values, ["username", "email"])).toEqual({
18
- username: "jane",
19
- email: "jane@example.com",
20
- });
21
- });
22
-
23
- it("picks nested fields", () => {
24
- const values = {
25
- invoiceLine: { product: "SKU-1", quantity: 2 },
26
- extra: "ignored",
27
- };
28
- expect(pickBySources(values, ["invoiceLine.product", "invoiceLine.quantity"])).toEqual({
29
- invoiceLine: { product: "SKU-1", quantity: 2 },
30
- });
31
- });
32
-
33
- it("returns all values when no sources registered", () => {
34
- const values = { a: 1, b: 2 };
35
- expect(pickBySources(values, [])).toEqual(values);
36
- });
37
- });
38
-
39
- describe("sort helpers", () => {
40
- it("toDjangoRestOrdering", () => {
41
- expect(toDjangoRestOrdering({ field: "username", order: "ASC" })).toBe(
42
- "username",
43
- );
44
- expect(toDjangoRestOrdering({ field: "username", order: "DESC" })).toBe(
45
- "-username",
46
- );
47
- expect(
48
- toDjangoRestOrdering([
49
- { field: "username", order: "ASC" },
50
- { field: "date_joined", order: "DESC" },
51
- ]),
52
- ).toBe("username,-date_joined");
53
- });
54
-
55
- it("toODataOrderBy", () => {
56
- expect(toODataOrderBy({ field: "username", order: "DESC" })).toBe(
57
- "username desc",
58
- );
59
- });
60
-
61
- it("toJsonApiSort", () => {
62
- expect(toJsonApiSort({ field: "username", order: "DESC" })).toBe(
63
- "-username",
64
- );
65
- });
66
- });
67
-
68
- describe("createRestResourceHandlers", () => {
69
- it("passes form payload through on create/update", async () => {
70
- const create = vi.fn(async (data) => ({ id: "1", ...data }));
71
- const update = vi.fn(async (_id, data) => ({ id: "1", ...data }));
72
-
73
- const handlers = createRestResourceHandlers({
74
- list: async () => ({ data: [], total: 0 }),
75
- retrieve: async () => ({ id: "1" }),
76
- create,
77
- update,
78
- destroy: async () => {},
79
- });
80
-
81
- await handlers.create({ username: "jane" });
82
- expect(create).toHaveBeenCalledWith({ username: "jane" });
83
-
84
- await handlers.update({ id: "1", data: { email: "a@b.c" } });
85
- expect(update).toHaveBeenCalledWith("1", { email: "a@b.c" });
86
- });
87
-
88
- it("applies transformCreate and transformUpdate when provided", async () => {
89
- const create = vi.fn(async (data) => data);
90
- const update = vi.fn(async (_id, data) => data);
91
-
92
- const handlers = createRestResourceHandlers({
93
- list: async () => ({ data: [], total: 0 }),
94
- retrieve: async () => ({}),
95
- create,
96
- update,
97
- destroy: async () => {},
98
- transformCreate: (data) => ({ user: data }),
99
- transformUpdate: (data) => ({ patch: data }),
100
- });
101
-
102
- await handlers.create({ username: "jane" });
103
- expect(create).toHaveBeenCalledWith({ user: { username: "jane" } });
104
-
105
- await handlers.update({ id: "1", data: { username: "bob" } });
106
- expect(update).toHaveBeenCalledWith("1", { patch: { username: "bob" } });
107
- });
108
- });
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { buildFormPayload } from "../crud/utils/buildFormPayload";
3
+ import { buildInlineRowsPayload } from "../crud/utils/buildInlineRowsPayload";
4
+ import { nestedFieldPath } from "../crud/utils/nestedFieldPath";
5
+ import { hasUploadValues } from "../crud/utils/hasUploadValues";
6
+ import { prepareFormSubmitBody } from "../crud/utils/prepareFormSubmitBody";
7
+ import { toFormData } from "../crud/utils/toFormData";
8
+ import { createRestResourceHandlers } from "./createRestResourceHandlers";
9
+ import {
10
+ toDjangoRestOrdering,
11
+ toJsonApiSort,
12
+ toODataOrderBy,
13
+ } from "./sortHelpers";
14
+
15
+ describe("buildFormPayload", () => {
16
+ it("picks flat fields", () => {
17
+ const values = {
18
+ username: "jane",
19
+ email: "jane@example.com",
20
+ tenants: [{ id: 1 }],
21
+ };
22
+ expect(buildFormPayload(values, ["username", "email"])).toEqual({
23
+ username: "jane",
24
+ email: "jane@example.com",
25
+ });
26
+ });
27
+
28
+ it("picks nested fields", () => {
29
+ const values = {
30
+ invoiceLine: { product: "SKU-1", quantity: 2 },
31
+ extra: "ignored",
32
+ };
33
+ expect(
34
+ buildFormPayload(values, ["invoiceLine.product", "invoiceLine.quantity"]),
35
+ ).toEqual({
36
+ invoiceLine: { product: "SKU-1", quantity: 2 },
37
+ });
38
+ });
39
+
40
+ it("returns all values when no field paths registered", () => {
41
+ const values = { a: 1, b: 2 };
42
+ expect(buildFormPayload(values, [])).toEqual(values);
43
+ });
44
+ });
45
+
46
+ describe("buildInlineRowsPayload", () => {
47
+ it("picks sources and keeps record id", () => {
48
+ const rows = [
49
+ { rowKey: "a", id: 10, label: "A", quantity: 2, extra: "x" },
50
+ { rowKey: "b", label: "B", quantity: 1 },
51
+ ];
52
+ expect(buildInlineRowsPayload(rows, ["label", "quantity"])).toEqual([
53
+ { label: "A", quantity: 2, id: 10 },
54
+ { label: "B", quantity: 1 },
55
+ ]);
56
+ });
57
+
58
+ it("applies transformRows", () => {
59
+ const rows = [{ rowKey: "a", label: "A" }];
60
+ const result = buildInlineRowsPayload(rows, ["label"], {
61
+ transformRows: (cleaned) => cleaned.map((r) => ({ ...r, kind: "line" })),
62
+ });
63
+ expect(result).toEqual([{ label: "A", kind: "line" }]);
64
+ });
65
+ });
66
+
67
+ describe("nestedFieldPath", () => {
68
+ it("builds dot paths", () => {
69
+ expect(nestedFieldPath("lines", 0, "label")).toBe("lines.0.label");
70
+ });
71
+ });
72
+
73
+ describe("hasUploadValues", () => {
74
+ it("detects File in nested payload", () => {
75
+ const file = new File(["x"], "a.png", { type: "image/png" });
76
+ expect(hasUploadValues({ name: "x" })).toBe(false);
77
+ expect(hasUploadValues({ photo: file })).toBe(true);
78
+ expect(hasUploadValues({ lines: [{ photo: file }] })).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("toFormData", () => {
83
+ it("encodes flat scalars and files", () => {
84
+ const file = new File(["x"], "photo.png", { type: "image/png" });
85
+ const fd = toFormData({ name: "Widget", active: true, count: 2, photo: file });
86
+
87
+ expect(fd.get("name")).toBe("Widget");
88
+ expect(fd.get("active")).toBe("true");
89
+ expect(fd.get("count")).toBe("2");
90
+ expect(fd.get("photo")).toBe(file);
91
+ });
92
+
93
+ it("encodes null as empty string and skips unchanged upload URLs", () => {
94
+ const fd = toFormData({
95
+ photo: null,
96
+ avatar: "https://cdn.example.com/a.jpg",
97
+ title: "Hello",
98
+ });
99
+
100
+ expect(fd.get("photo")).toBe("");
101
+ expect(fd.get("avatar")).toBeNull();
102
+ expect(fd.get("title")).toBe("Hello");
103
+ });
104
+
105
+ it("encodes inline rows with bracket notation", () => {
106
+ const file = new File(["x"], "line.png", { type: "image/png" });
107
+ const fd = toFormData({
108
+ lines: [{ id: 10, label: "A", photo: file }],
109
+ });
110
+
111
+ expect(fd.get("lines[0][id]")).toBe("10");
112
+ expect(fd.get("lines[0][label]")).toBe("A");
113
+ expect(fd.get("lines[0][photo]")).toBe(file);
114
+ });
115
+ });
116
+
117
+ describe("prepareFormSubmitBody", () => {
118
+ it("returns plain object when no uploads", () => {
119
+ const payload = { name: "Widget" };
120
+ expect(prepareFormSubmitBody(payload)).toEqual(payload);
121
+ });
122
+
123
+ it("omits unchanged upload references from JSON payloads", () => {
124
+ const body = prepareFormSubmitBody({
125
+ name: "Widget",
126
+ photo: "/media/employee_pictures/default.png",
127
+ });
128
+ expect(body).toEqual({ name: "Widget" });
129
+ });
130
+
131
+ it("returns FormData when uploads are present", () => {
132
+ const file = new File(["x"], "a.png", { type: "image/png" });
133
+ const body = prepareFormSubmitBody({ name: "Widget", photo: file });
134
+ expect(body).toBeInstanceOf(FormData);
135
+ expect((body as FormData).get("photo")).toBe(file);
136
+ });
137
+ });
138
+
139
+ describe("sort helpers", () => {
140
+ it("toDjangoRestOrdering", () => {
141
+ expect(toDjangoRestOrdering({ field: "username", order: "ASC" })).toBe(
142
+ "username",
143
+ );
144
+ expect(toDjangoRestOrdering({ field: "username", order: "DESC" })).toBe(
145
+ "-username",
146
+ );
147
+ expect(
148
+ toDjangoRestOrdering([
149
+ { field: "username", order: "ASC" },
150
+ { field: "date_joined", order: "DESC" },
151
+ ]),
152
+ ).toBe("username,-date_joined");
153
+ });
154
+
155
+ it("toODataOrderBy", () => {
156
+ expect(toODataOrderBy({ field: "username", order: "DESC" })).toBe(
157
+ "username desc",
158
+ );
159
+ });
160
+
161
+ it("toJsonApiSort", () => {
162
+ expect(toJsonApiSort({ field: "username", order: "DESC" })).toBe(
163
+ "-username",
164
+ );
165
+ });
166
+ });
167
+
168
+ describe("createRestResourceHandlers", () => {
169
+ it("passes form payload through on create/update", async () => {
170
+ const create = vi.fn(async (data) => ({ id: "1", ...data }));
171
+ const update = vi.fn(async (_id, data) => ({ id: "1", ...data }));
172
+
173
+ const handlers = createRestResourceHandlers({
174
+ list: async () => ({ data: [], total: 0 }),
175
+ retrieve: async () => ({ id: "1" }),
176
+ create,
177
+ update,
178
+ destroy: async () => {},
179
+ });
180
+
181
+ await handlers.create({ username: "jane" });
182
+ expect(create).toHaveBeenCalledWith({ username: "jane" });
183
+
184
+ await handlers.update({ id: "1", data: { email: "a@b.c" } });
185
+ expect(update).toHaveBeenCalledWith("1", { email: "a@b.c" });
186
+ });
187
+
188
+ it("applies transformCreate and transformUpdate when provided", async () => {
189
+ const create = vi.fn(async (data) => data);
190
+ const update = vi.fn(async (_id, data) => data);
191
+
192
+ const handlers = createRestResourceHandlers({
193
+ list: async () => ({ data: [], total: 0 }),
194
+ retrieve: async () => ({}),
195
+ create,
196
+ update,
197
+ destroy: async () => {},
198
+ transformCreate: (data) => ({ user: data }),
199
+ transformUpdate: (data) => ({ patch: data }),
200
+ });
201
+
202
+ await handlers.create({ username: "jane" });
203
+ expect(create).toHaveBeenCalledWith({ user: { username: "jane" } });
204
+
205
+ await handlers.update({ id: "1", data: { username: "bob" } });
206
+ expect(update).toHaveBeenCalledWith("1", { patch: { username: "bob" } });
207
+ });
208
+
209
+ it("passes FormData through without transform", async () => {
210
+ const create = vi.fn(async (data) => data);
211
+ const transformCreate = vi.fn((data) => data);
212
+
213
+ const handlers = createRestResourceHandlers({
214
+ list: async () => ({ data: [], total: 0 }),
215
+ retrieve: async () => ({}),
216
+ create,
217
+ update: async (_id, data) => data,
218
+ destroy: async () => {},
219
+ transformCreate,
220
+ });
221
+
222
+ const fd = new FormData();
223
+ fd.append("name", "Widget");
224
+ await handlers.create(fd);
225
+ expect(transformCreate).not.toHaveBeenCalled();
226
+ expect(create).toHaveBeenCalledWith(fd);
227
+ });
228
+ });