ding-react-admin 1.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 (237) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/dist/index.d.ts +1 -0
  4. package/dist/index.js +3067 -0
  5. package/dist/src/app/AdminApp.d.ts +7 -0
  6. package/dist/src/app/AdminApp.d.ts.map +1 -0
  7. package/dist/src/components/AuthAlternateLink.d.ts +7 -0
  8. package/dist/src/components/AuthAlternateLink.d.ts.map +1 -0
  9. package/dist/src/components/DensitySwitch.d.ts +2 -0
  10. package/dist/src/components/DensitySwitch.d.ts.map +1 -0
  11. package/dist/src/components/ThemeSwitch.d.ts +2 -0
  12. package/dist/src/components/ThemeSwitch.d.ts.map +1 -0
  13. package/dist/src/components/ThemeToolbar.d.ts +2 -0
  14. package/dist/src/components/ThemeToolbar.d.ts.map +1 -0
  15. package/dist/src/components/index.d.ts +5 -0
  16. package/dist/src/components/index.d.ts.map +1 -0
  17. package/dist/src/context/AppThemeProvider.d.ts +13 -0
  18. package/dist/src/context/AppThemeProvider.d.ts.map +1 -0
  19. package/dist/src/context/AuthProvider.d.ts +18 -0
  20. package/dist/src/context/AuthProvider.d.ts.map +1 -0
  21. package/dist/src/context/DataProvider.d.ts +10 -0
  22. package/dist/src/context/DataProvider.d.ts.map +1 -0
  23. package/dist/src/context/PermissionsProvider.d.ts +18 -0
  24. package/dist/src/context/PermissionsProvider.d.ts.map +1 -0
  25. package/dist/src/crud/FilterBar.d.ts +6 -0
  26. package/dist/src/crud/FilterBar.d.ts.map +1 -0
  27. package/dist/src/crud/FormSteps.d.ts +20 -0
  28. package/dist/src/crud/FormSteps.d.ts.map +1 -0
  29. package/dist/src/crud/FormTabs.d.ts +13 -0
  30. package/dist/src/crud/FormTabs.d.ts.map +1 -0
  31. package/dist/src/crud/InlineFormSet.d.ts +29 -0
  32. package/dist/src/crud/InlineFormSet.d.ts.map +1 -0
  33. package/dist/src/crud/ListActionsBar.d.ts +15 -0
  34. package/dist/src/crud/ListActionsBar.d.ts.map +1 -0
  35. package/dist/src/crud/ResourceForm.d.ts +21 -0
  36. package/dist/src/crud/ResourceForm.d.ts.map +1 -0
  37. package/dist/src/crud/ResourceFormModal.d.ts +10 -0
  38. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -0
  39. package/dist/src/crud/ResourceList.d.ts +38 -0
  40. package/dist/src/crud/ResourceList.d.ts.map +1 -0
  41. package/dist/src/crud/columns/BooleanColumn.d.ts +6 -0
  42. package/dist/src/crud/columns/BooleanColumn.d.ts.map +1 -0
  43. package/dist/src/crud/columns/CustomColumn.d.ts +9 -0
  44. package/dist/src/crud/columns/CustomColumn.d.ts.map +1 -0
  45. package/dist/src/crud/columns/DateColumn.d.ts +6 -0
  46. package/dist/src/crud/columns/DateColumn.d.ts.map +1 -0
  47. package/dist/src/crud/columns/NumberColumn.d.ts +6 -0
  48. package/dist/src/crud/columns/NumberColumn.d.ts.map +1 -0
  49. package/dist/src/crud/columns/ReferenceColumn.d.ts +6 -0
  50. package/dist/src/crud/columns/ReferenceColumn.d.ts.map +1 -0
  51. package/dist/src/crud/columns/ReferenceManyColumn.d.ts +6 -0
  52. package/dist/src/crud/columns/ReferenceManyColumn.d.ts.map +1 -0
  53. package/dist/src/crud/columns/TextColumn.d.ts +7 -0
  54. package/dist/src/crud/columns/TextColumn.d.ts.map +1 -0
  55. package/dist/src/crud/context/FilterContext.d.ts +17 -0
  56. package/dist/src/crud/context/FilterContext.d.ts.map +1 -0
  57. package/dist/src/crud/context/FormContext.d.ts +16 -0
  58. package/dist/src/crud/context/FormContext.d.ts.map +1 -0
  59. package/dist/src/crud/context/FormFieldsContext.d.ts +14 -0
  60. package/dist/src/crud/context/FormFieldsContext.d.ts.map +1 -0
  61. package/dist/src/crud/context/FormSectionContext.d.ts +7 -0
  62. package/dist/src/crud/context/FormSectionContext.d.ts.map +1 -0
  63. package/dist/src/crud/context/InlineFormContext.d.ts +16 -0
  64. package/dist/src/crud/context/InlineFormContext.d.ts.map +1 -0
  65. package/dist/src/crud/context/ListContext.d.ts +22 -0
  66. package/dist/src/crud/context/ListContext.d.ts.map +1 -0
  67. package/dist/src/crud/fields/BooleanField.d.ts +7 -0
  68. package/dist/src/crud/fields/BooleanField.d.ts.map +1 -0
  69. package/dist/src/crud/fields/DateField.d.ts +8 -0
  70. package/dist/src/crud/fields/DateField.d.ts.map +1 -0
  71. package/dist/src/crud/fields/FieldWrapper.d.ts +29 -0
  72. package/dist/src/crud/fields/FieldWrapper.d.ts.map +1 -0
  73. package/dist/src/crud/fields/NumberField.d.ts +12 -0
  74. package/dist/src/crud/fields/NumberField.d.ts.map +1 -0
  75. package/dist/src/crud/fields/PasswordField.d.ts +11 -0
  76. package/dist/src/crud/fields/PasswordField.d.ts.map +1 -0
  77. package/dist/src/crud/fields/ReferenceField.d.ts +18 -0
  78. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -0
  79. package/dist/src/crud/fields/ReferenceManyField.d.ts +9 -0
  80. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -0
  81. package/dist/src/crud/fields/SelectField.d.ts +10 -0
  82. package/dist/src/crud/fields/SelectField.d.ts.map +1 -0
  83. package/dist/src/crud/fields/TextField.d.ts +10 -0
  84. package/dist/src/crud/fields/TextField.d.ts.map +1 -0
  85. package/dist/src/crud/fields/useInlineOrFormField.d.ts +10 -0
  86. package/dist/src/crud/fields/useInlineOrFormField.d.ts.map +1 -0
  87. package/dist/src/crud/filters/BooleanFilter.d.ts +4 -0
  88. package/dist/src/crud/filters/BooleanFilter.d.ts.map +1 -0
  89. package/dist/src/crud/filters/DateFilter.d.ts +4 -0
  90. package/dist/src/crud/filters/DateFilter.d.ts.map +1 -0
  91. package/dist/src/crud/filters/NumberFilter.d.ts +4 -0
  92. package/dist/src/crud/filters/NumberFilter.d.ts.map +1 -0
  93. package/dist/src/crud/filters/ReferenceFilter.d.ts +13 -0
  94. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -0
  95. package/dist/src/crud/filters/SelectFilter.d.ts +7 -0
  96. package/dist/src/crud/filters/SelectFilter.d.ts.map +1 -0
  97. package/dist/src/crud/filters/TextFilter.d.ts +6 -0
  98. package/dist/src/crud/filters/TextFilter.d.ts.map +1 -0
  99. package/dist/src/crud/index.d.ts +44 -0
  100. package/dist/src/crud/index.d.ts.map +1 -0
  101. package/dist/src/crud/types.d.ts +99 -0
  102. package/dist/src/crud/types.d.ts.map +1 -0
  103. package/dist/src/crud/utils/formErrors.d.ts +9 -0
  104. package/dist/src/crud/utils/formErrors.d.ts.map +1 -0
  105. package/dist/src/crud/utils/formSectionErrors.d.ts +6 -0
  106. package/dist/src/crud/utils/formSectionErrors.d.ts.map +1 -0
  107. package/dist/src/crud/utils/getByPath.d.ts +3 -0
  108. package/dist/src/crud/utils/getByPath.d.ts.map +1 -0
  109. package/dist/src/crud/utils/inlineArrayName.d.ts +3 -0
  110. package/dist/src/crud/utils/inlineArrayName.d.ts.map +1 -0
  111. package/dist/src/crud/utils/pickBySources.d.ts +6 -0
  112. package/dist/src/crud/utils/pickBySources.d.ts.map +1 -0
  113. package/dist/src/crud/utils/setByPath.d.ts +3 -0
  114. package/dist/src/crud/utils/setByPath.d.ts.map +1 -0
  115. package/dist/src/crud/utils/sortQueryParam.d.ts +7 -0
  116. package/dist/src/crud/utils/sortQueryParam.d.ts.map +1 -0
  117. package/dist/src/crud/utils/useChoices.d.ts +10 -0
  118. package/dist/src/crud/utils/useChoices.d.ts.map +1 -0
  119. package/dist/src/crud/utils/useListQueryState.d.ts +22 -0
  120. package/dist/src/crud/utils/useListQueryState.d.ts.map +1 -0
  121. package/dist/src/data/createMemoryResourceHandlers.d.ts +23 -0
  122. package/dist/src/data/createMemoryResourceHandlers.d.ts.map +1 -0
  123. package/dist/src/data/createRestResourceHandlers.d.ts +22 -0
  124. package/dist/src/data/createRestResourceHandlers.d.ts.map +1 -0
  125. package/dist/src/data/dataProviderTypes.d.ts +67 -0
  126. package/dist/src/data/dataProviderTypes.d.ts.map +1 -0
  127. package/dist/src/data/dataUtils.test.d.ts +2 -0
  128. package/dist/src/data/dataUtils.test.d.ts.map +1 -0
  129. package/dist/src/data/inMemoryList.d.ts +12 -0
  130. package/dist/src/data/inMemoryList.d.ts.map +1 -0
  131. package/dist/src/data/parseFormErrorHelpers.d.ts +35 -0
  132. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -0
  133. package/dist/src/data/parseFormErrorHelpers.test.d.ts +2 -0
  134. package/dist/src/data/parseFormErrorHelpers.test.d.ts.map +1 -0
  135. package/dist/src/data/resourceHandlers.d.ts +33 -0
  136. package/dist/src/data/resourceHandlers.d.ts.map +1 -0
  137. package/dist/src/data/sortHelpers.d.ts +17 -0
  138. package/dist/src/data/sortHelpers.d.ts.map +1 -0
  139. package/dist/src/index.d.ts +31 -0
  140. package/dist/src/index.d.ts.map +1 -0
  141. package/dist/src/layouts/AdminLayout.d.ts +3 -0
  142. package/dist/src/layouts/AdminLayout.d.ts.map +1 -0
  143. package/dist/src/layouts/AuthPageLayout.d.ts +11 -0
  144. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -0
  145. package/dist/src/pages/LoginPage.d.ts +3 -0
  146. package/dist/src/pages/LoginPage.d.ts.map +1 -0
  147. package/dist/src/pages/PlaceholderPage.d.ts +4 -0
  148. package/dist/src/pages/PlaceholderPage.d.ts.map +1 -0
  149. package/dist/src/permissions/resourcePermissions.d.ts +22 -0
  150. package/dist/src/permissions/resourcePermissions.d.ts.map +1 -0
  151. package/dist/src/router/createAdminRouter.d.ts +3 -0
  152. package/dist/src/router/createAdminRouter.d.ts.map +1 -0
  153. package/dist/src/router/guards.d.ts +20 -0
  154. package/dist/src/router/guards.d.ts.map +1 -0
  155. package/dist/src/router/routeAccess.d.ts +21 -0
  156. package/dist/src/router/routeAccess.d.ts.map +1 -0
  157. package/dist/src/types.d.ts +123 -0
  158. package/dist/src/types.d.ts.map +1 -0
  159. package/package.json +58 -0
  160. package/src/app/AdminApp.tsx +34 -0
  161. package/src/components/AuthAlternateLink.tsx +23 -0
  162. package/src/components/DensitySwitch.tsx +31 -0
  163. package/src/components/ThemeSwitch.tsx +28 -0
  164. package/src/components/ThemeToolbar.tsx +34 -0
  165. package/src/components/index.ts +4 -0
  166. package/src/context/AppThemeProvider.tsx +128 -0
  167. package/src/context/AuthProvider.tsx +77 -0
  168. package/src/context/DataProvider.tsx +30 -0
  169. package/src/context/PermissionsProvider.tsx +57 -0
  170. package/src/crud/FilterBar.tsx +36 -0
  171. package/src/crud/FormSteps.tsx +135 -0
  172. package/src/crud/FormTabs.tsx +126 -0
  173. package/src/crud/InlineFormSet.tsx +231 -0
  174. package/src/crud/ListActionsBar.tsx +106 -0
  175. package/src/crud/ResourceForm.tsx +281 -0
  176. package/src/crud/ResourceFormModal.tsx +121 -0
  177. package/src/crud/ResourceList.tsx +633 -0
  178. package/src/crud/columns/BooleanColumn.tsx +32 -0
  179. package/src/crud/columns/CustomColumn.tsx +33 -0
  180. package/src/crud/columns/DateColumn.tsx +28 -0
  181. package/src/crud/columns/NumberColumn.tsx +31 -0
  182. package/src/crud/columns/ReferenceColumn.tsx +85 -0
  183. package/src/crud/columns/ReferenceManyColumn.tsx +67 -0
  184. package/src/crud/columns/TextColumn.tsx +38 -0
  185. package/src/crud/context/FilterContext.tsx +71 -0
  186. package/src/crud/context/FormContext.tsx +39 -0
  187. package/src/crud/context/FormFieldsContext.tsx +76 -0
  188. package/src/crud/context/FormSectionContext.tsx +22 -0
  189. package/src/crud/context/InlineFormContext.tsx +71 -0
  190. package/src/crud/context/ListContext.tsx +93 -0
  191. package/src/crud/fields/BooleanField.tsx +32 -0
  192. package/src/crud/fields/DateField.tsx +47 -0
  193. package/src/crud/fields/FieldWrapper.tsx +109 -0
  194. package/src/crud/fields/NumberField.tsx +50 -0
  195. package/src/crud/fields/PasswordField.tsx +98 -0
  196. package/src/crud/fields/ReferenceField.tsx +191 -0
  197. package/src/crud/fields/ReferenceManyField.tsx +70 -0
  198. package/src/crud/fields/SelectField.tsx +42 -0
  199. package/src/crud/fields/TextField.tsx +44 -0
  200. package/src/crud/fields/useInlineOrFormField.tsx +91 -0
  201. package/src/crud/filters/BooleanFilter.tsx +38 -0
  202. package/src/crud/filters/DateFilter.tsx +35 -0
  203. package/src/crud/filters/NumberFilter.tsx +33 -0
  204. package/src/crud/filters/ReferenceFilter.tsx +173 -0
  205. package/src/crud/filters/SelectFilter.tsx +44 -0
  206. package/src/crud/filters/TextFilter.tsx +36 -0
  207. package/src/crud/index.ts +73 -0
  208. package/src/crud/types.ts +130 -0
  209. package/src/crud/utils/formErrors.ts +38 -0
  210. package/src/crud/utils/formSectionErrors.ts +85 -0
  211. package/src/crud/utils/getByPath.ts +13 -0
  212. package/src/crud/utils/inlineArrayName.ts +4 -0
  213. package/src/crud/utils/pickBySources.ts +24 -0
  214. package/src/crud/utils/setByPath.ts +18 -0
  215. package/src/crud/utils/sortQueryParam.ts +37 -0
  216. package/src/crud/utils/useChoices.ts +182 -0
  217. package/src/crud/utils/useListQueryState.ts +191 -0
  218. package/src/data/createMemoryResourceHandlers.ts +78 -0
  219. package/src/data/createRestResourceHandlers.ts +57 -0
  220. package/src/data/dataProviderTypes.ts +101 -0
  221. package/src/data/dataUtils.test.ts +108 -0
  222. package/src/data/inMemoryList.ts +97 -0
  223. package/src/data/parseFormErrorHelpers.test.ts +84 -0
  224. package/src/data/parseFormErrorHelpers.ts +224 -0
  225. package/src/data/resourceHandlers.ts +124 -0
  226. package/src/data/sortHelpers.ts +48 -0
  227. package/src/index.ts +182 -0
  228. package/src/layouts/AdminLayout.tsx +381 -0
  229. package/src/layouts/AuthPageLayout.tsx +81 -0
  230. package/src/pages/LoginPage.tsx +80 -0
  231. package/src/pages/PlaceholderPage.tsx +9 -0
  232. package/src/permissions/resourcePermissions.ts +47 -0
  233. package/src/router/createAdminRouter.tsx +64 -0
  234. package/src/router/guards.tsx +63 -0
  235. package/src/router/routeAccess.ts +89 -0
  236. package/src/types.ts +135 -0
  237. package/src/vite-env.d.ts +1 -0
@@ -0,0 +1,108 @@
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
+ });
@@ -0,0 +1,97 @@
1
+ import type { GetListParams, Identifier, SortSpec } from "./dataProviderTypes";
2
+
3
+ export function rowIdMatches(
4
+ rowId: string | number,
5
+ id: Identifier,
6
+ ): boolean {
7
+ return rowId === id || String(rowId) === String(id);
8
+ }
9
+
10
+ export function getById<T extends { id: string | number }>(
11
+ rows: T[],
12
+ id: Identifier,
13
+ ): T {
14
+ const row = rows.find((r) => rowIdMatches(r.id, id));
15
+ if (!row) throw new Error("Not found");
16
+ return row;
17
+ }
18
+
19
+ function sortRows<T extends Record<string, unknown>>(
20
+ rows: T[],
21
+ specs: SortSpec[],
22
+ ): T[] {
23
+ if (specs.length === 0) return rows;
24
+ const mul = (order: "ASC" | "DESC") => (order === "DESC" ? -1 : 1);
25
+ return [...rows].sort((a, b) => {
26
+ for (const { field, order } of specs) {
27
+ const av = a[field];
28
+ const bv = b[field];
29
+ if (av === bv) continue;
30
+ if (av == null) return -1 * mul(order);
31
+ if (bv == null) return 1 * mul(order);
32
+ if (typeof av === "number" && typeof bv === "number") {
33
+ return av < bv ? -mul(order) : mul(order);
34
+ }
35
+ const cmp = String(av).localeCompare(String(bv));
36
+ if (cmp !== 0) return cmp * mul(order);
37
+ }
38
+ return 0;
39
+ });
40
+ }
41
+
42
+ function matchFilterValue(rowVal: unknown, filterVal: unknown): boolean {
43
+ if (filterVal === undefined || filterVal === null || filterVal === "") {
44
+ return true;
45
+ }
46
+ if (Array.isArray(filterVal)) {
47
+ if (filterVal.length === 0) return true;
48
+ if (Array.isArray(rowVal)) {
49
+ return filterVal.some((fv) => rowVal.includes(fv));
50
+ }
51
+ return filterVal.includes(rowVal);
52
+ }
53
+ if (Array.isArray(rowVal)) {
54
+ return rowVal.includes(filterVal);
55
+ }
56
+ if (typeof filterVal === "string" && typeof rowVal === "string") {
57
+ return rowVal.toLowerCase().includes(filterVal.toLowerCase());
58
+ }
59
+ return rowVal === filterVal;
60
+ }
61
+
62
+ export function filterRows<T extends Record<string, unknown>>(
63
+ rows: T[],
64
+ filter?: Record<string, unknown>,
65
+ ): T[] {
66
+ if (!filter) return rows;
67
+ return rows.filter((row) =>
68
+ Object.entries(filter).every(([k, v]) => matchFilterValue(row[k], v)),
69
+ );
70
+ }
71
+
72
+ function paginate<T>(rows: T[], page: number, perPage: number) {
73
+ const start = (page - 1) * perPage;
74
+ return {
75
+ data: rows.slice(start, start + perPage),
76
+ total: rows.length,
77
+ };
78
+ }
79
+
80
+ /** Apply CRUD list filters, sort, and pagination to an in-memory row array. */
81
+ export function applyInMemoryListParams<T extends Record<string, unknown>>(
82
+ rows: T[],
83
+ params: GetListParams,
84
+ ): { data: T[]; total: number } {
85
+ const { pagination, sort, filter } = params;
86
+ let out = filterRows(rows, filter);
87
+ if (sort) {
88
+ const specs = Array.isArray(sort) ? sort : [sort];
89
+ if (specs.length > 0 && specs[0]?.field) {
90
+ out = sortRows(out, specs);
91
+ }
92
+ }
93
+ if (pagination) {
94
+ return paginate(out, pagination.page, pagination.perPage);
95
+ }
96
+ return { data: out, total: out.length };
97
+ }
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ parseDjangoDRFFormErrors,
4
+ parseDotNetFormErrors,
5
+ parseNodeFormErrors,
6
+ } from "./parseFormErrorHelpers";
7
+
8
+ const mainCtx = { resource: "users", mutation: "create" as const };
9
+ const inlineCtx = {
10
+ resource: "invoice-lines",
11
+ mutation: "create" as const,
12
+ inlineArrayName: "__inline_invoice_lines",
13
+ rowIndex: 1,
14
+ };
15
+
16
+ describe("parseDjangoDRFFormErrors", () => {
17
+ it("maps field and non_field_errors", () => {
18
+ const parsed = parseDjangoDRFFormErrors(
19
+ {
20
+ body: {
21
+ email: ["Enter a valid email."],
22
+ non_field_errors: ["Cannot save"],
23
+ },
24
+ },
25
+ mainCtx,
26
+ );
27
+ expect(parsed).toEqual({
28
+ fields: { email: "Enter a valid email." },
29
+ global: ["Cannot save"],
30
+ });
31
+ });
32
+
33
+ it("prefixes inline row fields", () => {
34
+ const parsed = parseDjangoDRFFormErrors(
35
+ { body: { quantity: ["Must be positive"] } },
36
+ inlineCtx,
37
+ );
38
+ expect(parsed?.fields).toEqual({
39
+ "__inline_invoice_lines.1.quantity": "Must be positive",
40
+ });
41
+ });
42
+ });
43
+
44
+ describe("parseDotNetFormErrors", () => {
45
+ it("maps errors object with camelCase", () => {
46
+ const parsed = parseDotNetFormErrors(
47
+ {
48
+ body: {
49
+ errors: { Email: ["The Email field is required."] },
50
+ },
51
+ },
52
+ mainCtx,
53
+ );
54
+ expect(parsed?.fields).toEqual({
55
+ email: "The Email field is required.",
56
+ });
57
+ });
58
+ });
59
+
60
+ describe("parseNodeFormErrors", () => {
61
+ it("maps express-validator array", () => {
62
+ const parsed = parseNodeFormErrors(
63
+ {
64
+ body: {
65
+ errors: [{ path: "email", msg: "Invalid email" }],
66
+ },
67
+ },
68
+ mainCtx,
69
+ );
70
+ expect(parsed?.fields).toEqual({ email: "Invalid email" });
71
+ });
72
+
73
+ it("maps Joi details", () => {
74
+ const parsed = parseNodeFormErrors(
75
+ {
76
+ body: {
77
+ details: [{ message: "Required", path: ["email"] }],
78
+ },
79
+ },
80
+ mainCtx,
81
+ );
82
+ expect(parsed?.fields).toEqual({ email: "Required" });
83
+ });
84
+ });
@@ -0,0 +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
+ /** 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
+ }
@@ -0,0 +1,124 @@
1
+ import type { PermissionsChecker } from "../context/PermissionsProvider";
2
+ import type { ResourcePermissions } from "../permissions/resourcePermissions";
3
+ import { checkResourcePermission } from "../permissions/resourcePermissions";
4
+ import type {
5
+ CreateResult,
6
+ DataProvider,
7
+ DeleteResult,
8
+ GetListParams,
9
+ GetListResult,
10
+ GetOneResult,
11
+ Identifier,
12
+ ParseFormError,
13
+ UpdateParams,
14
+ UpdateResult,
15
+ } from "./dataProviderTypes";
16
+
17
+ export type ResourceAction = "list" | "read" | "add" | "change" | "delete";
18
+
19
+ /** @deprecated Use per-resource `permissions` with `can` in combineResourceHandlers options. */
20
+ export type ResourceGuard = (
21
+ resource: string,
22
+ action: ResourceAction,
23
+ ) => void;
24
+
25
+ /** CRUD handlers for a single resource (no resource name on each method). */
26
+ export type ResourceHandlers<
27
+ RecordType extends Record<string, unknown> = Record<string, unknown>,
28
+ > = {
29
+ getList: (params: GetListParams) => Promise<GetListResult<RecordType>>;
30
+ getOne: (id: Identifier) => Promise<GetOneResult<RecordType>>;
31
+ create: (data: Partial<RecordType>) => Promise<CreateResult<RecordType>>;
32
+ update: (
33
+ params: UpdateParams<RecordType>,
34
+ ) => Promise<UpdateResult<RecordType>>;
35
+ delete: (id: Identifier) => Promise<DeleteResult<RecordType>>;
36
+ };
37
+
38
+ export type ResourceHandlerEntry =
39
+ | ResourceHandlers
40
+ | {
41
+ handlers: ResourceHandlers;
42
+ permissions?: ResourcePermissions;
43
+ };
44
+
45
+ export type ResourceHandlerMap = Record<string, ResourceHandlerEntry>;
46
+
47
+ export type CombineResourceHandlersOptions = {
48
+ /** Permission checker from PermissionsProvider wiring. */
49
+ can?: PermissionsChecker;
50
+ /** @deprecated Use per-resource `permissions` entries instead. */
51
+ guard?: ResourceGuard;
52
+ /** Wired to `ResourceForm` / `ResourceFormModal` save error handling. */
53
+ parseFormError?: ParseFormError;
54
+ };
55
+
56
+ function resolveEntry(entry: ResourceHandlerEntry): {
57
+ handlers: ResourceHandlers;
58
+ permissions?: ResourcePermissions;
59
+ } {
60
+ if ("handlers" in entry) return entry;
61
+ return { handlers: entry };
62
+ }
63
+
64
+ function assertPermission(
65
+ can: PermissionsChecker | undefined,
66
+ permissions: ResourcePermissions | undefined,
67
+ slot: keyof ResourcePermissions,
68
+ ) {
69
+ if (!can || !permissions) return;
70
+ if (!checkResourcePermission(can, permissions, slot)) {
71
+ throw new Error("Forbidden");
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Compose per-entity handlers into a react-admin-style `DataProvider`.
77
+ * Map an OpenAPI / tRPC / REST client in each entity module, then register here.
78
+ */
79
+ export function combineResourceHandlers(
80
+ handlers: ResourceHandlerMap,
81
+ options?: CombineResourceHandlersOptions,
82
+ ): DataProvider {
83
+ const { can, guard, parseFormError } = options ?? {};
84
+
85
+ const resolve = (resource: string) => {
86
+ const entry = handlers[resource];
87
+ if (!entry) throw new Error(`Unknown resource: ${resource}`);
88
+ return resolveEntry(entry);
89
+ };
90
+
91
+ return {
92
+ async getList(resource, params) {
93
+ const { handlers: h, permissions } = resolve(resource);
94
+ guard?.(resource, "list");
95
+ assertPermission(can, permissions, "list");
96
+ return h.getList(params);
97
+ },
98
+ async getOne(resource, id) {
99
+ const { handlers: h, permissions } = resolve(resource);
100
+ guard?.(resource, "read");
101
+ assertPermission(can, permissions, "read");
102
+ return h.getOne(id);
103
+ },
104
+ async create(resource, data) {
105
+ const { handlers: h, permissions } = resolve(resource);
106
+ guard?.(resource, "add");
107
+ assertPermission(can, permissions, "add");
108
+ return h.create(data);
109
+ },
110
+ async update(resource, params) {
111
+ const { handlers: h, permissions } = resolve(resource);
112
+ guard?.(resource, "change");
113
+ assertPermission(can, permissions, "change");
114
+ return h.update(params);
115
+ },
116
+ async delete(resource, id) {
117
+ const { handlers: h, permissions } = resolve(resource);
118
+ guard?.(resource, "delete");
119
+ assertPermission(can, permissions, "delete");
120
+ return h.delete(id);
121
+ },
122
+ parseFormError,
123
+ };
124
+ }
@@ -0,0 +1,48 @@
1
+ import type { SortSpec } from "./dataProviderTypes";
2
+
3
+ function normalizeSort(sort?: SortSpec | SortSpec[]): SortSpec[] {
4
+ if (!sort) return [];
5
+ return Array.isArray(sort) ? sort : [sort];
6
+ }
7
+
8
+ /**
9
+ * Django REST Framework `ordering` query param.
10
+ * ASC → `username`; DESC → `-username`; multi → `username,-date_joined`.
11
+ */
12
+ export function toDjangoRestOrdering(
13
+ sort?: SortSpec | SortSpec[],
14
+ ): string | undefined {
15
+ const specs = normalizeSort(sort);
16
+ if (specs.length === 0) return undefined;
17
+ return specs
18
+ .map((s) => (s.order === "DESC" ? `-${s.field}` : s.field))
19
+ .join(",");
20
+ }
21
+
22
+ /**
23
+ * OData `$orderby` query param.
24
+ * ASC → `username asc`; DESC → `username desc`.
25
+ */
26
+ export function toODataOrderBy(
27
+ sort?: SortSpec | SortSpec[],
28
+ ): string | undefined {
29
+ const specs = normalizeSort(sort);
30
+ if (specs.length === 0) return undefined;
31
+ return specs
32
+ .map((s) => `${s.field} ${s.order === "DESC" ? "desc" : "asc"}`)
33
+ .join(",");
34
+ }
35
+
36
+ /**
37
+ * Common JSON:API `sort` query param.
38
+ * ASC → `username`; DESC → `-username`.
39
+ */
40
+ export function toJsonApiSort(
41
+ sort?: SortSpec | SortSpec[],
42
+ ): string | undefined {
43
+ const specs = normalizeSort(sort);
44
+ if (specs.length === 0) return undefined;
45
+ return specs
46
+ .map((s) => (s.order === "DESC" ? `-${s.field}` : s.field))
47
+ .join(",");
48
+ }