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,633 @@
1
+ import { CaretDownOutlined, CaretUpOutlined } from "@ant-design/icons";
2
+ import { App, Button, Card, Space, Table, Typography } from "antd";
3
+ import type { ColumnsType } from "antd/es/table";
4
+ import type {
5
+ SortOrder as AntSortOrder,
6
+ SorterResult,
7
+ } from "antd/es/table/interface";
8
+ import {
9
+ createContext,
10
+ useCallback,
11
+ useContext,
12
+ useEffect,
13
+ useMemo,
14
+ useState,
15
+ type Key,
16
+ type ReactNode,
17
+ } from "react";
18
+ import { Link } from "react-router-dom";
19
+ import { useDataProvider } from "../context/DataProvider";
20
+ import { usePermissions } from "../context/PermissionsProvider";
21
+ import type { ResourcePermissions } from "../permissions/resourcePermissions";
22
+ import { checkResourcePermission } from "../permissions/resourcePermissions";
23
+ import { ListContextProvider, useListContext } from "./context/ListContext";
24
+ import { FilterContextProvider } from "./context/FilterContext";
25
+ import { ResourceFormModal } from "./ResourceFormModal";
26
+ import { ListActionsBar } from "./ListActionsBar";
27
+ import type {
28
+ EditMode,
29
+ ResourceListBuiltInActions,
30
+ ResourceListBulkAction,
31
+ ResourceListBulkActionHelpers,
32
+ ResourceListRowActionsHelpers,
33
+ } from "./types";
34
+ import { useListQueryState } from "./utils/useListQueryState";
35
+
36
+ type ResourceListContextValue = {
37
+ filterValues: Record<string, unknown>;
38
+ setFilterValue: (source: string, value: unknown) => void;
39
+ };
40
+
41
+ const ResourceListContext = createContext<ResourceListContextValue | null>(
42
+ null,
43
+ );
44
+
45
+ export function useResourceListContext() {
46
+ return useContext(ResourceListContext);
47
+ }
48
+
49
+ export type ResourceListProps = {
50
+ resource: string;
51
+ title: string;
52
+ pathPrefix: string;
53
+ newPath?: string;
54
+ /** Merged into every list request (not synced to URL). */
55
+ staticFilter?: Record<string, unknown>;
56
+ editMode?: EditMode;
57
+ syncQueryParams?: boolean;
58
+ children: ReactNode;
59
+ /** Form fields rendered inside create/edit modal. */
60
+ formChildren?: ReactNode;
61
+ /** Show/hide built-in Edit, Quick edit, and Delete actions (permissions still apply). */
62
+ actions?: ResourceListBuiltInActions;
63
+ /** Extra row actions rendered after built-in ones in the Actions column. */
64
+ rowActions?: (
65
+ row: Record<string, unknown>,
66
+ helpers: ResourceListRowActionsHelpers,
67
+ ) => ReactNode;
68
+ /** Extra card header content, rendered before New / New page buttons. */
69
+ headerExtra?: ReactNode;
70
+ /** Extra bulk actions in the Django-style action bar (appended after built-ins). */
71
+ bulkActions?: ResourceListBulkAction[];
72
+ /** Show built-in "Delete selected" when user has delete permission. Default true. */
73
+ bulkDelete?: boolean;
74
+ /** Set false to hide row checkboxes and the bulk action bar. Default true. */
75
+ bulkActionsEnabled?: boolean;
76
+ /** Permission strings for built-in actions. Omit to allow all (demos only). */
77
+ permissions?: ResourcePermissions;
78
+ };
79
+
80
+ function ResourceListTable<T extends Record<string, unknown>>({
81
+ resource,
82
+ title,
83
+ pathPrefix,
84
+ newPath,
85
+ editMode = "page",
86
+ formChildren,
87
+ actions: actionsConfig,
88
+ rowActions,
89
+ headerExtra,
90
+ bulkActions: bulkActionsProp,
91
+ bulkDelete = true,
92
+ bulkActionsEnabled = true,
93
+ permissions,
94
+ queryState,
95
+ queryActions,
96
+ }: {
97
+ resource: string;
98
+ title: string;
99
+ pathPrefix: string;
100
+ newPath?: string;
101
+ editMode?: EditMode;
102
+ formChildren?: ReactNode;
103
+ actions?: ResourceListBuiltInActions;
104
+ rowActions?: (
105
+ row: Record<string, unknown>,
106
+ helpers: ResourceListRowActionsHelpers,
107
+ ) => ReactNode;
108
+ headerExtra?: ReactNode;
109
+ bulkActions?: ResourceListBulkAction[];
110
+ bulkDelete?: boolean;
111
+ bulkActionsEnabled?: boolean;
112
+ permissions?: ResourcePermissions;
113
+ queryState: ReturnType<typeof useListQueryState>[0];
114
+ queryActions: ReturnType<typeof useListQueryState>[1];
115
+ }) {
116
+ const dp = useDataProvider();
117
+ const can = usePermissions();
118
+ const { message, modal } = App.useApp();
119
+ const { columns, sortOrders, sortPriorities } = useListContext();
120
+ const [loading, setLoading] = useState(false);
121
+ const [data, setData] = useState<T[]>([]);
122
+ const [total, setTotal] = useState(0);
123
+ const [selectedIds, setSelectedIds] = useState<Set<string | number>>(
124
+ () => new Set(),
125
+ );
126
+ const [bulkRunning, setBulkRunning] = useState(false);
127
+
128
+ const createHref = newPath ?? `${pathPrefix}/new`;
129
+ const canAdd = checkResourcePermission(can, permissions, "add");
130
+ const canChange = checkResourcePermission(can, permissions, "change");
131
+ const canDelete = checkResourcePermission(can, permissions, "delete");
132
+ const showEdit =
133
+ canChange &&
134
+ (editMode === "page" || editMode === "both") &&
135
+ actionsConfig?.edit !== false;
136
+ const showQuickEdit =
137
+ canChange &&
138
+ (editMode === "modal" || editMode === "both") &&
139
+ actionsConfig?.quickEdit !== false;
140
+ const showDelete = canDelete && actionsConfig?.delete !== false;
141
+ const showActionsColumn = showEdit || showQuickEdit || showDelete || rowActions;
142
+
143
+ const clearSelection = useCallback(() => {
144
+ setSelectedIds(new Set());
145
+ }, []);
146
+
147
+ const availableBulkActions = useMemo((): ResourceListBulkAction[] => {
148
+ if (!bulkActionsEnabled) return [];
149
+ const built: ResourceListBulkAction[] = [];
150
+ if (bulkDelete && canDelete) {
151
+ built.push({
152
+ key: "__delete",
153
+ label: "Delete selected",
154
+ confirm: (ids) =>
155
+ `Delete ${ids.length} selected item(s)? This cannot be undone.`,
156
+ execute: async (ids, { reload, clearSelection: clear }) => {
157
+ await Promise.all(
158
+ ids.map((id) => dp.delete(resource, id as string | number)),
159
+ );
160
+ clear();
161
+ reload();
162
+ message.success(`Deleted ${ids.length} item(s)`);
163
+ },
164
+ });
165
+ }
166
+ return [...built, ...(bulkActionsProp ?? [])];
167
+ }, [
168
+ bulkActionsEnabled,
169
+ bulkDelete,
170
+ canDelete,
171
+ bulkActionsProp,
172
+ dp,
173
+ resource,
174
+ message,
175
+ ]);
176
+
177
+ const showBulkActions = availableBulkActions.length > 0;
178
+
179
+ const selectedCount = selectedIds.size;
180
+ const allPageSelected =
181
+ data.length > 0 &&
182
+ data.every((row) => selectedIds.has(row.id as string | number));
183
+ const allMatchingSelected = total > 0 && selectedCount >= total;
184
+
185
+ const selectedRowKeys = useMemo((): Key[] => {
186
+ return data
187
+ .filter((row) => selectedIds.has(row.id as string | number))
188
+ .map((row) => row.id as Key);
189
+ }, [data, selectedIds]);
190
+
191
+ const handleRowSelectionChange = useCallback(
192
+ (keys: Key[]) => {
193
+ setSelectedIds((prev) => {
194
+ const next = new Set(prev);
195
+ const pageIds = data.map((row) => row.id as string | number);
196
+ for (const id of pageIds) {
197
+ if (!keys.includes(id)) next.delete(id);
198
+ }
199
+ for (const key of keys) {
200
+ next.add(key as string | number);
201
+ }
202
+ return next;
203
+ });
204
+ },
205
+ [data],
206
+ );
207
+
208
+ const handleSelectAllMatching = useCallback(async () => {
209
+ if (total <= 0) return;
210
+ setBulkRunning(true);
211
+ try {
212
+ const sort =
213
+ queryState.sort.length === 0
214
+ ? undefined
215
+ : queryState.sort.length === 1
216
+ ? queryState.sort[0]
217
+ : queryState.sort;
218
+ const res = await dp.getList(resource, {
219
+ pagination: { page: 1, perPage: total },
220
+ sort,
221
+ filter: queryState.filter,
222
+ });
223
+ setSelectedIds(
224
+ new Set(res.data.map((row) => row.id as string | number)),
225
+ );
226
+ } catch (e) {
227
+ message.error(e instanceof Error ? e.message : "Load failed");
228
+ } finally {
229
+ setBulkRunning(false);
230
+ }
231
+ }, [dp, resource, total, queryState.sort, queryState.filter, message]);
232
+
233
+ const handleTableSortChange = useCallback(
234
+ (sorter: SorterResult<T> | SorterResult<T>[]) => {
235
+ const resolveField = (entry: SorterResult<T> | undefined) => {
236
+ const raw = entry?.columnKey ?? entry?.field;
237
+ if (raw == null) return null;
238
+ return String(Array.isArray(raw) ? raw[0] : raw);
239
+ };
240
+
241
+ if (Array.isArray(sorter)) {
242
+ const active = sorter.find((entry) => entry?.order);
243
+ if (active) {
244
+ const field = resolveField(active);
245
+ if (field) queryActions.toggleSort(field);
246
+ return;
247
+ }
248
+ if (queryState.sort.length > 0) queryActions.setSort([]);
249
+ return;
250
+ }
251
+
252
+ const field = resolveField(sorter);
253
+ if (field) {
254
+ queryActions.toggleSort(field);
255
+ return;
256
+ }
257
+
258
+ // Ant Design "cancel sort" omits field/order (legacy generateSorterInfo).
259
+ if (!sorter?.order && queryState.sort.length > 0) {
260
+ queryActions.setSort([]);
261
+ }
262
+ },
263
+ [queryActions, queryState.sort.length],
264
+ );
265
+
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]);
288
+
289
+ useEffect(() => {
290
+ void load();
291
+ }, [load]);
292
+
293
+ const bulkActionHelpers = useMemo(
294
+ (): ResourceListBulkActionHelpers => ({
295
+ reload: () => void load(),
296
+ clearSelection,
297
+ }),
298
+ [load, clearSelection],
299
+ );
300
+
301
+ const handleBulkExecute = useCallback(
302
+ async (action: ResourceListBulkAction, ids: (string | number)[]) => {
303
+ if (action.confirm) {
304
+ const confirmMsg =
305
+ typeof action.confirm === "function"
306
+ ? await action.confirm(ids, bulkActionHelpers)
307
+ : action.confirm;
308
+ if (confirmMsg === false) return;
309
+ const confirmed = await new Promise<boolean>((resolve) => {
310
+ modal.confirm({
311
+ title: confirmMsg,
312
+ okType: action.key === "__delete" ? "danger" : "primary",
313
+ onOk: () => resolve(true),
314
+ onCancel: () => resolve(false),
315
+ });
316
+ });
317
+ if (!confirmed) return;
318
+ }
319
+
320
+ setBulkRunning(true);
321
+ try {
322
+ await action.execute(ids, bulkActionHelpers);
323
+ } catch (e) {
324
+ message.error(e instanceof Error ? e.message : "Action failed");
325
+ } finally {
326
+ setBulkRunning(false);
327
+ }
328
+ },
329
+ [bulkActionHelpers, modal, message],
330
+ );
331
+
332
+ const handleDelete = useCallback(
333
+ async (row: T) => {
334
+ if (!canDelete) return;
335
+ try {
336
+ await dp.delete(resource, row.id as string | number);
337
+ message.success("Deleted");
338
+ void load();
339
+ } catch (e) {
340
+ message.error(e instanceof Error ? e.message : "Delete failed");
341
+ }
342
+ },
343
+ [canDelete, dp, resource, load, message],
344
+ );
345
+
346
+ const tableColumns = useMemo((): ColumnsType<T> => {
347
+ const built = columns.map((c) => {
348
+ const col = c.buildColumn() as ColumnsType<T>[number];
349
+ if (c.sortable) {
350
+ const order = sortOrders.get(c.source);
351
+ const priority = sortPriorities.get(c.source);
352
+ const antOrder: AntSortOrder | undefined =
353
+ order === "ASC"
354
+ ? "ascend"
355
+ : order === "DESC"
356
+ ? "descend"
357
+ : undefined;
358
+ const sortedIcon =
359
+ antOrder != null ? (
360
+ <span
361
+ style={{
362
+ display: "inline-flex",
363
+ alignItems: "center",
364
+ gap: 2,
365
+ marginInlineStart: 4,
366
+ color: "var(--ant-color-primary)",
367
+ }}
368
+ >
369
+ {priority != null ? (
370
+ <span
371
+ style={{
372
+ fontSize: 11,
373
+ fontWeight: 600,
374
+ lineHeight: 1,
375
+ minWidth: 10,
376
+ textAlign: "center",
377
+ }}
378
+ >
379
+ {priority}
380
+ </span>
381
+ ) : null}
382
+ {antOrder === "ascend" ? (
383
+ <CaretUpOutlined style={{ fontSize: 11 }} />
384
+ ) : (
385
+ <CaretDownOutlined style={{ fontSize: 11 }} />
386
+ )}
387
+ </span>
388
+ ) : undefined;
389
+ return {
390
+ ...col,
391
+ sorter: true,
392
+ sortOrder: antOrder,
393
+ ...(sortedIcon ? { sortIcon: () => sortedIcon } : {}),
394
+ };
395
+ }
396
+ return col;
397
+ });
398
+
399
+ if (!showActionsColumn) return built;
400
+
401
+ const rowHelpers: ResourceListRowActionsHelpers = {
402
+ reload: () => void load(),
403
+ openEditModal: queryActions.openEditModal,
404
+ };
405
+
406
+ const action: ColumnsType<T>[number] = {
407
+ title: "Actions",
408
+ key: "__actions",
409
+ width: editMode === "both" ? 200 : 160,
410
+ render: (_, row) => (
411
+ <Space size="small" wrap>
412
+ {showEdit ? (
413
+ <Link to={`${pathPrefix}/${String(row.id)}`}>Edit</Link>
414
+ ) : null}
415
+ {showQuickEdit ? (
416
+ <Button
417
+ type="link"
418
+ size="small"
419
+ style={{ padding: 0 }}
420
+ onClick={() =>
421
+ queryActions.openEditModal(row.id as string | number)
422
+ }
423
+ >
424
+ {editMode === "both" ? "Quick edit" : "Edit"}
425
+ </Button>
426
+ ) : null}
427
+ {showDelete ? (
428
+ <Button
429
+ type="link"
430
+ danger
431
+ size="small"
432
+ onClick={() => void handleDelete(row)}
433
+ style={{ padding: 0 }}
434
+ >
435
+ Delete
436
+ </Button>
437
+ ) : null}
438
+ {rowActions?.(row, rowHelpers)}
439
+ </Space>
440
+ ),
441
+ };
442
+ return [...built, action];
443
+ }, [
444
+ columns,
445
+ showActionsColumn,
446
+ showEdit,
447
+ showQuickEdit,
448
+ showDelete,
449
+ editMode,
450
+ pathPrefix,
451
+ handleDelete,
452
+ sortOrders,
453
+ sortPriorities,
454
+ queryActions,
455
+ rowActions,
456
+ load,
457
+ ]);
458
+
459
+ const showModal =
460
+ formChildren &&
461
+ (queryState.createModal || queryState.editId != null) &&
462
+ (editMode === "modal" || editMode === "both");
463
+
464
+ return (
465
+ <>
466
+ <Card
467
+ title={
468
+ <Typography.Title level={5} style={{ margin: 0 }}>
469
+ {title}
470
+ </Typography.Title>
471
+ }
472
+ extra={
473
+ (headerExtra || canAdd) ? (
474
+ <Space>
475
+ {headerExtra}
476
+ {canAdd ? (
477
+ editMode === "modal" || editMode === "both" ? (
478
+ <>
479
+ {editMode === "both" ? (
480
+ <Link to={createHref}>
481
+ <Button>New page</Button>
482
+ </Link>
483
+ ) : null}
484
+ <Button
485
+ type="primary"
486
+ onClick={() => queryActions.openCreateModal()}
487
+ >
488
+ New
489
+ </Button>
490
+ </>
491
+ ) : (
492
+ <Link to={createHref}>
493
+ <Button type="primary">New</Button>
494
+ </Link>
495
+ )
496
+ ) : null}
497
+ </Space>
498
+ ) : null
499
+ }
500
+ >
501
+ {showBulkActions ? (
502
+ <ListActionsBar
503
+ selectedCount={selectedCount}
504
+ total={total}
505
+ allPageSelected={allPageSelected}
506
+ allMatchingSelected={allMatchingSelected}
507
+ onSelectAllMatching={() => void handleSelectAllMatching()}
508
+ onClearSelection={clearSelection}
509
+ actions={availableBulkActions}
510
+ onExecute={handleBulkExecute}
511
+ selectedIds={[...selectedIds]}
512
+ running={bulkRunning || loading}
513
+ />
514
+ ) : null}
515
+ <Table<T>
516
+ rowKey="id"
517
+ loading={loading}
518
+ columns={tableColumns}
519
+ dataSource={data}
520
+ rowSelection={
521
+ showBulkActions
522
+ ? {
523
+ selectedRowKeys,
524
+ onChange: handleRowSelectionChange,
525
+ preserveSelectedRowKeys: true,
526
+ }
527
+ : undefined
528
+ }
529
+ pagination={{
530
+ current: queryState.page,
531
+ pageSize: queryState.perPage,
532
+ total,
533
+ showSizeChanger: true,
534
+ onChange: (p, ps) => {
535
+ queryActions.setPage(p);
536
+ if (ps) queryActions.setPerPage(ps);
537
+ },
538
+ }}
539
+ onChange={(_pag, _filters, sorter) => {
540
+ handleTableSortChange(sorter as SorterResult<T> | SorterResult<T>[]);
541
+ }}
542
+ />
543
+ </Card>
544
+ {showModal ? (
545
+ <ResourceFormModal
546
+ resource={resource}
547
+ editId={queryState.createModal ? "new" : queryState.editId}
548
+ onClose={() => {
549
+ queryActions.closeModal();
550
+ void load();
551
+ }}
552
+ >
553
+ {formChildren}
554
+ </ResourceFormModal>
555
+ ) : null}
556
+ </>
557
+ );
558
+ }
559
+
560
+ export function ResourceList({
561
+ resource,
562
+ title,
563
+ pathPrefix,
564
+ newPath,
565
+ staticFilter,
566
+ editMode = "page",
567
+ syncQueryParams = true,
568
+ children,
569
+ formChildren,
570
+ actions,
571
+ rowActions,
572
+ headerExtra,
573
+ bulkActions,
574
+ bulkDelete,
575
+ bulkActionsEnabled,
576
+ permissions,
577
+ }: ResourceListProps) {
578
+ const [queryState, queryActions] = useListQueryState(staticFilter);
579
+
580
+ const filterValues = useMemo(() => {
581
+ if (!syncQueryParams) return staticFilter ?? {};
582
+ const vals: Record<string, unknown> = {};
583
+ for (const [k, v] of Object.entries(queryState.filter)) {
584
+ if (staticFilter && k in staticFilter) continue;
585
+ vals[k] = v;
586
+ }
587
+ return vals;
588
+ }, [queryState.filter, staticFilter, syncQueryParams]);
589
+
590
+ const setFilterValue = useCallback(
591
+ (source: string, value: unknown) => {
592
+ if (syncQueryParams) {
593
+ queryActions.setFilter(source, value);
594
+ }
595
+ },
596
+ [syncQueryParams, queryActions],
597
+ );
598
+
599
+ const listCtx = useMemo(
600
+ (): ResourceListContextValue => ({ filterValues, setFilterValue }),
601
+ [filterValues, setFilterValue],
602
+ );
603
+
604
+ return (
605
+ <ResourceListContext.Provider value={listCtx}>
606
+ <FilterContextProvider values={filterValues} setFilterValue={setFilterValue}>
607
+ <ListContextProvider
608
+ toggleSort={queryActions.toggleSort}
609
+ sort={queryState.sort}
610
+ >
611
+ {children}
612
+ <ResourceListTable
613
+ resource={resource}
614
+ title={title}
615
+ pathPrefix={pathPrefix}
616
+ newPath={newPath}
617
+ editMode={editMode}
618
+ formChildren={formChildren}
619
+ actions={actions}
620
+ rowActions={rowActions}
621
+ headerExtra={headerExtra}
622
+ bulkActions={bulkActions}
623
+ bulkDelete={bulkDelete}
624
+ bulkActionsEnabled={bulkActionsEnabled}
625
+ permissions={permissions}
626
+ queryState={queryState}
627
+ queryActions={queryActions}
628
+ />
629
+ </ListContextProvider>
630
+ </FilterContextProvider>
631
+ </ResourceListContext.Provider>
632
+ );
633
+ }
@@ -0,0 +1,32 @@
1
+ import { useMemo } from "react";
2
+ import type { BaseSourceProps } from "../types";
3
+ import { useRegisterColumn } from "../context/ListContext";
4
+
5
+ export type BooleanColumnProps = BaseSourceProps & {
6
+ sortable?: boolean;
7
+ };
8
+
9
+ export function BooleanColumn({
10
+ source,
11
+ label,
12
+ sortable = true,
13
+ }: BooleanColumnProps) {
14
+ const def = useMemo(
15
+ () => ({
16
+ key: source,
17
+ source,
18
+ label,
19
+ sortable,
20
+ buildColumn: () => ({
21
+ title: label ?? source,
22
+ dataIndex: source,
23
+ key: source,
24
+ sorter: sortable ? true : undefined,
25
+ render: (v: boolean) => (v ? "Yes" : "No"),
26
+ }),
27
+ }),
28
+ [source, label, sortable],
29
+ );
30
+ useRegisterColumn(def);
31
+ return null;
32
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo, type ReactNode } from "react";
2
+ import { useRegisterColumn } from "../context/ListContext";
3
+
4
+ export type CustomColumnProps = {
5
+ source: string;
6
+ label?: string;
7
+ sortable?: boolean;
8
+ render: (record: Record<string, unknown>) => ReactNode;
9
+ };
10
+
11
+ export function CustomColumn({
12
+ source,
13
+ label,
14
+ sortable = false,
15
+ render,
16
+ }: CustomColumnProps) {
17
+ const def = useMemo(
18
+ () => ({
19
+ key: source,
20
+ source,
21
+ label,
22
+ sortable,
23
+ buildColumn: () => ({
24
+ title: label ?? source,
25
+ key: source,
26
+ render: (_: unknown, record: Record<string, unknown>) => render(record),
27
+ }),
28
+ }),
29
+ [source, label, sortable, render],
30
+ );
31
+ useRegisterColumn(def);
32
+ return null;
33
+ }