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,381 +1,530 @@
1
- import {
2
- LogoutOutlined,
3
- MenuOutlined,
4
- UserOutlined,
5
- } from "@ant-design/icons";
6
- import {
7
- Avatar,
8
- Button,
9
- Drawer,
10
- Dropdown,
11
- Grid,
12
- Layout,
13
- Menu,
14
- theme,
15
- Typography,
16
- } from "antd";
17
- import type { MenuProps } from "antd";
18
- import { useCallback, useEffect, useMemo, useState } from "react";
19
- import { Outlet, useLocation, useNavigate } from "react-router-dom";
20
- import { ThemeToolbar } from "../components/ThemeToolbar";
21
- import { useAuth } from "../context/AuthProvider";
22
- import { usePermissions } from "../context/PermissionsProvider";
23
- import { useThemeMode } from "../context/AppThemeProvider";
24
- import { filterNavByPermission } from "../permissions/resourcePermissions";
25
- import type { AdminLayoutProps, NavItem } from "../types";
26
-
27
- const LIGHT_SIDEBAR_BG = "#001529";
28
- const DEFAULT_SIDER_KEY = "ding-react-admin-sider-collapsed";
29
-
30
- function readSiderCollapsed(key: string): boolean {
31
- try {
32
- return localStorage.getItem(key) === "1";
33
- } catch {
34
- return false;
35
- }
36
- }
37
-
38
- function useIsMobileNav(): boolean {
39
- const screens = Grid.useBreakpoint();
40
- return screens.lg !== true;
41
- }
42
-
43
- /** Paths that correspond to actual routes (leaf menu rows only). */
44
- function collectLeafPaths(items: NavItem[]): Set<string> {
45
- const set = new Set<string>();
46
- function walk(nodes: NavItem[]) {
47
- for (const n of nodes) {
48
- if (n.children?.length) walk(n.children);
49
- else set.add(n.path);
50
- }
51
- }
52
- walk(items);
53
- return set;
54
- }
55
-
56
- /** Submenu keys that must stay open for `pathname` to remain visible in the tree. */
57
- function ancestorKeysForActivePath(
58
- items: NavItem[],
59
- pathname: string,
60
- ): string[] {
61
- function dfs(nodes: NavItem[]): string[] | null {
62
- for (const node of nodes) {
63
- if (node.children?.length) {
64
- const sub = dfs(node.children);
65
- if (sub !== null) return [node.path, ...sub];
66
- } else if (node.path === pathname) {
67
- return [];
68
- }
69
- }
70
- return null;
71
- }
72
- return dfs(items) ?? [];
73
- }
74
-
75
- function navItemsToAntdItems(
76
- items: NavItem[],
77
- ): NonNullable<MenuProps["items"]> {
78
- return items.map((item) => {
79
- const IconComp = item.Icon;
80
- const icon = IconComp ? <IconComp /> : undefined;
81
- if (item.children?.length) {
82
- return {
83
- key: item.path,
84
- icon,
85
- label: item.label,
86
- children: navItemsToAntdItems(item.children),
87
- };
88
- }
89
- return {
90
- key: item.path,
91
- icon,
92
- label: item.label,
93
- };
94
- });
95
- }
96
-
97
- type NavMenuProps = {
98
- menuItems: MenuProps["items"];
99
- selectedKeys: string[];
100
- inlineCollapsed: boolean;
101
- openKeys?: string[];
102
- onOpenChange?: (keys: string[]) => void;
103
- onNavigate: (key: string) => void;
104
- };
105
-
106
- function NavMenu({
107
- menuItems,
108
- selectedKeys,
109
- inlineCollapsed,
110
- openKeys,
111
- onOpenChange,
112
- onNavigate,
113
- }: NavMenuProps) {
114
- return (
115
- <Menu
116
- mode="inline"
117
- theme="dark"
118
- inlineCollapsed={inlineCollapsed}
119
- selectedKeys={selectedKeys}
120
- {...(!inlineCollapsed && openKeys !== undefined && onOpenChange
121
- ? { openKeys, onOpenChange }
122
- : {})}
123
- items={menuItems}
124
- onClick={({ key }) => onNavigate(key)}
125
- style={{ background: "transparent", borderInlineEnd: "none" }}
126
- />
127
- );
128
- }
129
-
130
- export function AdminLayout({
131
- navItems,
132
- brand = "Admin",
133
- collapsedBrand = "A",
134
- mobileDrawerTitle,
135
- headerExtras,
136
- userMenuItems,
137
- onUserMenuClick,
138
- loginPath = "/login",
139
- siderCollapsedStorageKey = DEFAULT_SIDER_KEY,
140
- }: AdminLayoutProps) {
141
- const navigate = useNavigate();
142
- const location = useLocation();
143
- const { resolved } = useThemeMode();
144
- const isAppDark = resolved === "dark";
145
- const { logout } = useAuth();
146
- const can = usePermissions();
147
- const [collapsed, setCollapsed] = useState(() =>
148
- readSiderCollapsed(siderCollapsedStorageKey),
149
- );
150
- const [mobileNavOpen, setMobileNavOpen] = useState(false);
151
- const isMobile = useIsMobileNav();
152
- const { token } = theme.useToken();
153
-
154
- const drawerTitle = mobileDrawerTitle ?? brand;
155
-
156
- const handleLogout = () => {
157
- logout();
158
- navigate(loginPath, { replace: true });
159
- };
160
-
161
- const handleCollapse = useCallback(
162
- (next: boolean) => {
163
- setCollapsed(next);
164
- try {
165
- localStorage.setItem(siderCollapsedStorageKey, next ? "1" : "0");
166
- } catch {
167
- /* ignore */
168
- }
169
- },
170
- [siderCollapsedStorageKey],
171
- );
172
-
173
- useEffect(() => {
174
- if (!isMobile) setMobileNavOpen(false);
175
- }, [isMobile]);
176
-
177
- useEffect(() => {
178
- setMobileNavOpen(false);
179
- }, [location.pathname]);
180
-
181
- const visibleNavItems = useMemo(
182
- () => filterNavByPermission(navItems, can),
183
- [navItems, can],
184
- );
185
-
186
- const leafPaths = useMemo(
187
- () => collectLeafPaths(visibleNavItems),
188
- [visibleNavItems],
189
- );
190
-
191
- const menuItems: MenuProps["items"] = useMemo(
192
- () => navItemsToAntdItems(visibleNavItems),
193
- [visibleNavItems],
194
- );
195
-
196
- const requiredOpenKeys = useMemo(
197
- () => ancestorKeysForActivePath(visibleNavItems, location.pathname),
198
- [visibleNavItems, location.pathname],
199
- );
200
-
201
- const [openKeys, setOpenKeys] = useState<string[]>(() =>
202
- ancestorKeysForActivePath(visibleNavItems, location.pathname),
203
- );
204
-
205
- useEffect(() => {
206
- setOpenKeys((prev) => [...new Set([...prev, ...requiredOpenKeys])]);
207
- }, [requiredOpenKeys]);
208
-
209
- const handleOpenChange = useCallback((keys: string[]) => {
210
- setOpenKeys(keys);
211
- }, []);
212
-
213
- const defaultUserMenuItems: MenuProps["items"] = useMemo(
214
- () => [
215
- {
216
- key: "logout",
217
- icon: <LogoutOutlined />,
218
- label: "Log out",
219
- danger: true,
220
- },
221
- ],
222
- [],
223
- );
224
-
225
- const resolvedUserItems = userMenuItems ?? defaultUserMenuItems;
226
-
227
- const onDropdownMenuClick: MenuProps["onClick"] = (info) => {
228
- if (onUserMenuClick) {
229
- onUserMenuClick(info);
230
- return;
231
- }
232
- if (info.key === "logout") handleLogout();
233
- };
234
-
235
- const siderBg = isAppDark ? token.colorBgContainer : LIGHT_SIDEBAR_BG;
236
- const selectedKeys = [location.pathname];
237
-
238
- const goNav = (key: string) => {
239
- if (!leafPaths.has(key)) return;
240
- navigate(key);
241
- if (isMobile) setMobileNavOpen(false);
242
- };
243
-
244
- return (
245
- <Layout
246
- style={{
247
- minHeight: "100vh",
248
- width: "100%",
249
- background: token.colorBgLayout,
250
- }}
251
- >
252
- {!isMobile && (
253
- <Layout.Sider
254
- collapsible
255
- collapsed={collapsed}
256
- onCollapse={handleCollapse}
257
- collapsedWidth={64}
258
- style={{
259
- background: siderBg,
260
- borderInlineEnd: isAppDark
261
- ? `1px solid ${token.colorSplit}`
262
- : undefined,
263
- }}
264
- >
265
- <div
266
- style={{
267
- height: 64,
268
- display: "flex",
269
- alignItems: "center",
270
- justifyContent: "center",
271
- fontWeight: 600,
272
- }}
273
- >
274
- <Typography.Text
275
- strong
276
- style={{ color: token.colorTextLightSolid }}
277
- >
278
- {collapsed ? collapsedBrand : brand}
279
- </Typography.Text>
280
- </div>
281
- <NavMenu
282
- menuItems={menuItems}
283
- selectedKeys={selectedKeys}
284
- inlineCollapsed={collapsed}
285
- openKeys={openKeys}
286
- onOpenChange={handleOpenChange}
287
- onNavigate={goNav}
288
- />
289
- </Layout.Sider>
290
- )}
291
- {isMobile && (
292
- <Drawer
293
- title={
294
- <Typography.Text
295
- strong
296
- style={{ color: token.colorTextLightSolid }}
297
- >
298
- {drawerTitle}
299
- </Typography.Text>
300
- }
301
- placement="left"
302
- size={280}
303
- onClose={() => setMobileNavOpen(false)}
304
- open={mobileNavOpen}
305
- styles={{
306
- header: {
307
- background: siderBg,
308
- borderBottom: `1px solid ${token.colorSplit}`,
309
- },
310
- body: { padding: 0, background: siderBg },
311
- }}
312
- destroyOnHidden
313
- >
314
- <NavMenu
315
- menuItems={menuItems}
316
- selectedKeys={selectedKeys}
317
- inlineCollapsed={false}
318
- openKeys={openKeys}
319
- onOpenChange={handleOpenChange}
320
- onNavigate={goNav}
321
- />
322
- </Drawer>
323
- )}
324
- <Layout>
325
- <Layout.Header
326
- style={{
327
- background: token.colorBgContainer,
328
- paddingInline: token.paddingLG,
329
- display: "flex",
330
- alignItems: "center",
331
- gap: token.marginSM,
332
- lineHeight: "normal",
333
- }}
334
- >
335
- {isMobile && (
336
- <Button
337
- type="text"
338
- icon={<MenuOutlined />}
339
- onClick={() => setMobileNavOpen(true)}
340
- aria-label="Open navigation"
341
- />
342
- )}
343
- <div style={{ flex: 1, minWidth: 0 }} />
344
- {headerExtras}
345
- <ThemeToolbar />
346
- <Dropdown
347
- menu={{
348
- items: resolvedUserItems,
349
- onClick: onDropdownMenuClick,
350
- }}
351
- trigger={["click"]}
352
- >
353
- <Button
354
- type="text"
355
- style={{
356
- display: "inline-flex",
357
- alignItems: "center",
358
- gap: token.marginXS,
359
- maxWidth: isMobile ? 44 : undefined,
360
- paddingInline: isMobile ? token.paddingXS : undefined,
361
- }}
362
- aria-label="Account menu"
363
- >
364
- <Avatar size="small" icon={<UserOutlined />} />
365
- {!isMobile && (
366
- <Typography.Text type="secondary">User</Typography.Text>
367
- )}
368
- </Button>
369
- </Dropdown>
370
- </Layout.Header>
371
- <Layout.Content
372
- style={{
373
- margin: isMobile ? token.marginSM : token.marginLG,
374
- }}
375
- >
376
- <Outlet />
377
- </Layout.Content>
378
- </Layout>
379
- </Layout>
380
- );
381
- }
1
+ import {
2
+ LogoutOutlined,
3
+ MenuOutlined,
4
+ UserOutlined,
5
+ } from "@ant-design/icons";
6
+ import {
7
+ Avatar,
8
+ Button,
9
+ Drawer,
10
+ Dropdown,
11
+ Grid,
12
+ Layout,
13
+ Menu,
14
+ theme,
15
+ Typography,
16
+ } from "antd";
17
+ import type { MenuProps } from "antd";
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
+ import { Outlet, useLocation, useNavigate } from "react-router-dom";
20
+ import { ThemeToolbar } from "../components/ThemeToolbar";
21
+ import { NavMenuSearch } from "../components/NavMenuSearch";
22
+ import { ScrollableArea } from "../components/ScrollableArea";
23
+ import { useAuth } from "../context/AuthProvider";
24
+ import { usePermissions } from "../context/PermissionsProvider";
25
+ import { useThemeMode } from "../context/AppThemeProvider";
26
+ import {
27
+ collectSubmenuKeys,
28
+ filterNavItems,
29
+ } from "./navFilter";
30
+ import { navItemsToAntdItems } from "./navMenuItems";
31
+ import { filterNavByPermission } from "../permissions/resourcePermissions";
32
+ import type { AdminLayoutProps, NavItem } from "../types";
33
+ import "../components/navMenu.css";
34
+
35
+ const LIGHT_SIDEBAR_BG = "#001529";
36
+ const DEFAULT_SIDER_KEY = "ding-react-admin-sider-collapsed";
37
+
38
+ function readSiderCollapsed(key: string): boolean {
39
+ try {
40
+ return localStorage.getItem(key) === "1";
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function useIsMobileNav(): boolean {
47
+ const screens = Grid.useBreakpoint();
48
+ return screens.lg !== true;
49
+ }
50
+
51
+ /** Paths that correspond to actual routes (leaf menu rows only). */
52
+ function collectLeafPaths(items: NavItem[]): Set<string> {
53
+ const set = new Set<string>();
54
+ function walk(nodes: NavItem[]) {
55
+ for (const n of nodes) {
56
+ if (n.children?.length) walk(n.children);
57
+ else set.add(n.path);
58
+ }
59
+ }
60
+ walk(items);
61
+ return set;
62
+ }
63
+
64
+ /** Submenu keys that must stay open for `pathname` to remain visible in the tree. */
65
+ function ancestorKeysForActivePath(
66
+ items: NavItem[],
67
+ pathname: string,
68
+ ): string[] {
69
+ function dfs(nodes: NavItem[]): string[] | null {
70
+ for (const node of nodes) {
71
+ if (node.children?.length) {
72
+ const sub = dfs(node.children);
73
+ if (sub !== null) return [node.path, ...sub];
74
+ } else if (node.path === pathname) {
75
+ return [];
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ return dfs(items) ?? [];
81
+ }
82
+
83
+ type NavMenuProps = {
84
+ menuItems: MenuProps["items"];
85
+ selectedKeys: string[];
86
+ inlineCollapsed: boolean;
87
+ openKeys?: string[];
88
+ onOpenChange?: (keys: string[]) => void;
89
+ onNavigate: (key: string) => void;
90
+ };
91
+
92
+ type SiderNavPanelProps = NavMenuProps & {
93
+ navQuery: string;
94
+ onNavQueryChange: (value: string) => void;
95
+ showNavSearch: boolean;
96
+ navSearchPlaceholder?: string;
97
+ scrollVariant: "default" | "on-dark";
98
+ searchVariant: "on-dark" | "app";
99
+ };
100
+
101
+ function SiderNavPanel({
102
+ menuItems,
103
+ selectedKeys,
104
+ inlineCollapsed,
105
+ openKeys,
106
+ onOpenChange,
107
+ onNavigate,
108
+ navQuery,
109
+ onNavQueryChange,
110
+ showNavSearch,
111
+ navSearchPlaceholder,
112
+ scrollVariant,
113
+ searchVariant,
114
+ }: SiderNavPanelProps) {
115
+ return (
116
+ <>
117
+ {showNavSearch && !inlineCollapsed ? (
118
+ <NavMenuSearch
119
+ value={navQuery}
120
+ onChange={onNavQueryChange}
121
+ placeholder={navSearchPlaceholder}
122
+ variant={searchVariant}
123
+ />
124
+ ) : null}
125
+ <ScrollableArea
126
+ variant={scrollVariant}
127
+ style={{
128
+ flex: 1,
129
+ minHeight: 0,
130
+ overflowY: "auto",
131
+ overflowX: "hidden",
132
+ }}
133
+ >
134
+ <NavMenu
135
+ menuItems={menuItems}
136
+ selectedKeys={selectedKeys}
137
+ inlineCollapsed={inlineCollapsed}
138
+ openKeys={openKeys}
139
+ onOpenChange={onOpenChange}
140
+ onNavigate={onNavigate}
141
+ />
142
+ </ScrollableArea>
143
+ </>
144
+ );
145
+ }
146
+
147
+ function NavMenu({
148
+ menuItems,
149
+ selectedKeys,
150
+ inlineCollapsed,
151
+ openKeys,
152
+ onOpenChange,
153
+ onNavigate,
154
+ }: NavMenuProps) {
155
+ return (
156
+ <Menu
157
+ className="ding-admin-nav-menu"
158
+ mode="inline"
159
+ theme="dark"
160
+ inlineCollapsed={inlineCollapsed}
161
+ selectedKeys={selectedKeys}
162
+ tooltip={{ placement: "right", mouseEnterDelay: 1 }}
163
+ {...(!inlineCollapsed && openKeys !== undefined && onOpenChange
164
+ ? { openKeys, onOpenChange }
165
+ : {})}
166
+ items={menuItems}
167
+ onClick={({ key }) => onNavigate(key)}
168
+ style={{ background: "transparent", borderInlineEnd: "none" }}
169
+ />
170
+ );
171
+ }
172
+
173
+ export function AdminLayout({
174
+ navItems,
175
+ brand = "Admin",
176
+ collapsedBrand = "A",
177
+ mobileDrawerTitle,
178
+ headerExtras,
179
+ userMenuItems,
180
+ onUserMenuClick,
181
+ loginPath = "/login",
182
+ siderCollapsedStorageKey = DEFAULT_SIDER_KEY,
183
+ navSearch = true,
184
+ }: AdminLayoutProps) {
185
+ const navigate = useNavigate();
186
+ const location = useLocation();
187
+ const { resolved } = useThemeMode();
188
+ const isAppDark = resolved === "dark";
189
+ const { logout, userLabel } = useAuth();
190
+ const can = usePermissions();
191
+ const [collapsed, setCollapsed] = useState(() =>
192
+ readSiderCollapsed(siderCollapsedStorageKey),
193
+ );
194
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
195
+ const isMobile = useIsMobileNav();
196
+ const { token } = theme.useToken();
197
+ const contentRef = useRef<HTMLDivElement>(null);
198
+ const [navQuery, setNavQuery] = useState("");
199
+
200
+ const navSearchEnabled = navSearch !== false;
201
+ const navSearchPlaceholder =
202
+ typeof navSearch === "object" ? navSearch.placeholder : undefined;
203
+
204
+ const drawerTitle = mobileDrawerTitle ?? brand;
205
+
206
+ const handleLogout = () => {
207
+ logout();
208
+ navigate(loginPath, { replace: true });
209
+ };
210
+
211
+ const handleCollapse = useCallback(
212
+ (next: boolean) => {
213
+ setCollapsed(next);
214
+ try {
215
+ localStorage.setItem(siderCollapsedStorageKey, next ? "1" : "0");
216
+ } catch {
217
+ /* ignore */
218
+ }
219
+ },
220
+ [siderCollapsedStorageKey],
221
+ );
222
+
223
+ useEffect(() => {
224
+ if (!isMobile) setMobileNavOpen(false);
225
+ }, [isMobile]);
226
+
227
+ useEffect(() => {
228
+ setMobileNavOpen(false);
229
+ }, [location.pathname]);
230
+
231
+ useEffect(() => {
232
+ contentRef.current?.scrollTo({ top: 0, left: 0 });
233
+ }, [location.pathname]);
234
+
235
+ useEffect(() => {
236
+ if (collapsed) setNavQuery("");
237
+ }, [collapsed]);
238
+
239
+ const visibleNavItems = useMemo(
240
+ () => filterNavByPermission(navItems, can),
241
+ [navItems, can],
242
+ );
243
+
244
+ const trimmedNavQuery = navQuery.trim();
245
+ const isNavFiltering = trimmedNavQuery.length > 0;
246
+
247
+ const displayNavItems = useMemo(
248
+ () =>
249
+ isNavFiltering
250
+ ? filterNavItems(visibleNavItems, trimmedNavQuery)
251
+ : visibleNavItems,
252
+ [visibleNavItems, trimmedNavQuery, isNavFiltering],
253
+ );
254
+
255
+ const leafPaths = useMemo(
256
+ () => collectLeafPaths(displayNavItems),
257
+ [displayNavItems],
258
+ );
259
+
260
+ const menuItems: MenuProps["items"] = useMemo(
261
+ () =>
262
+ navItemsToAntdItems(displayNavItems, {
263
+ showLabelTooltip: !collapsed,
264
+ }),
265
+ [displayNavItems, collapsed],
266
+ );
267
+
268
+ const filteredOpenKeys = useMemo(
269
+ () => collectSubmenuKeys(displayNavItems),
270
+ [displayNavItems],
271
+ );
272
+
273
+ const requiredOpenKeys = useMemo(
274
+ () => ancestorKeysForActivePath(visibleNavItems, location.pathname),
275
+ [visibleNavItems, location.pathname],
276
+ );
277
+
278
+ const [openKeys, setOpenKeys] = useState<string[]>(() =>
279
+ ancestorKeysForActivePath(visibleNavItems, location.pathname),
280
+ );
281
+
282
+ useEffect(() => {
283
+ setOpenKeys((prev) => [...new Set([...prev, ...requiredOpenKeys])]);
284
+ }, [requiredOpenKeys]);
285
+
286
+ const handleOpenChange = useCallback((keys: string[]) => {
287
+ setOpenKeys(keys);
288
+ }, []);
289
+
290
+ const effectiveOpenKeys = isNavFiltering ? filteredOpenKeys : openKeys;
291
+
292
+ const handleNavQueryChange = useCallback((value: string) => {
293
+ setNavQuery(value);
294
+ }, []);
295
+
296
+ const defaultUserMenuItems: MenuProps["items"] = useMemo(
297
+ () => [
298
+ {
299
+ key: "logout",
300
+ icon: <LogoutOutlined />,
301
+ label: "Log out",
302
+ danger: true,
303
+ },
304
+ ],
305
+ [],
306
+ );
307
+
308
+ const resolvedUserItems = userMenuItems ?? defaultUserMenuItems;
309
+
310
+ const onDropdownMenuClick: MenuProps["onClick"] = (info) => {
311
+ if (onUserMenuClick) {
312
+ onUserMenuClick(info);
313
+ return;
314
+ }
315
+ if (info.key === "logout") handleLogout();
316
+ };
317
+
318
+ const siderBg = isAppDark ? token.colorBgContainer : LIGHT_SIDEBAR_BG;
319
+ const siderScrollVariant = isAppDark ? "default" : "on-dark";
320
+ const siderSearchVariant = isAppDark ? "app" : "on-dark";
321
+ const selectedKeys = [location.pathname];
322
+
323
+ const goNav = (key: string) => {
324
+ if (!leafPaths.has(key)) return;
325
+ navigate(key);
326
+ if (isMobile) setMobileNavOpen(false);
327
+ };
328
+
329
+ return (
330
+ <Layout
331
+ style={{
332
+ height: "100vh",
333
+ width: "100%",
334
+ overflow: "hidden",
335
+ background: token.colorBgLayout,
336
+ }}
337
+ >
338
+ {!isMobile && (
339
+ <Layout.Sider
340
+ collapsible
341
+ collapsed={collapsed}
342
+ onCollapse={handleCollapse}
343
+ collapsedWidth={64}
344
+ style={{
345
+ background: siderBg,
346
+ height: "100vh",
347
+ overflow: "hidden",
348
+ borderInlineEnd: isAppDark
349
+ ? `1px solid ${token.colorSplit}`
350
+ : undefined,
351
+ }}
352
+ >
353
+ <div
354
+ style={{
355
+ display: "flex",
356
+ flexDirection: "column",
357
+ height: "100%",
358
+ overflow: "hidden",
359
+ }}
360
+ >
361
+ <div
362
+ style={{
363
+ height: 64,
364
+ flexShrink: 0,
365
+ display: "flex",
366
+ alignItems: "center",
367
+ justifyContent: "center",
368
+ fontWeight: 600,
369
+ }}
370
+ >
371
+ <Typography.Text
372
+ strong
373
+ style={{ color: token.colorTextLightSolid }}
374
+ >
375
+ {collapsed ? collapsedBrand : brand}
376
+ </Typography.Text>
377
+ </div>
378
+ <SiderNavPanel
379
+ menuItems={menuItems}
380
+ selectedKeys={selectedKeys}
381
+ inlineCollapsed={collapsed}
382
+ openKeys={effectiveOpenKeys}
383
+ onOpenChange={handleOpenChange}
384
+ onNavigate={goNav}
385
+ navQuery={navQuery}
386
+ onNavQueryChange={handleNavQueryChange}
387
+ showNavSearch={navSearchEnabled}
388
+ navSearchPlaceholder={navSearchPlaceholder}
389
+ scrollVariant={siderScrollVariant}
390
+ searchVariant={siderSearchVariant}
391
+ />
392
+ </div>
393
+ </Layout.Sider>
394
+ )}
395
+ {isMobile && (
396
+ <Drawer
397
+ title={
398
+ <Typography.Text
399
+ strong
400
+ style={{ color: token.colorTextLightSolid }}
401
+ >
402
+ {drawerTitle}
403
+ </Typography.Text>
404
+ }
405
+ placement="left"
406
+ size={280}
407
+ onClose={() => setMobileNavOpen(false)}
408
+ open={mobileNavOpen}
409
+ styles={{
410
+ header: {
411
+ background: siderBg,
412
+ borderBottom: `1px solid ${token.colorSplit}`,
413
+ },
414
+ body: { padding: 0, background: siderBg },
415
+ }}
416
+ destroyOnHidden
417
+ >
418
+ <div
419
+ style={{
420
+ display: "flex",
421
+ flexDirection: "column",
422
+ height: "100%",
423
+ overflow: "hidden",
424
+ }}
425
+ >
426
+ <SiderNavPanel
427
+ menuItems={menuItems}
428
+ selectedKeys={selectedKeys}
429
+ inlineCollapsed={false}
430
+ openKeys={effectiveOpenKeys}
431
+ onOpenChange={handleOpenChange}
432
+ onNavigate={goNav}
433
+ navQuery={navQuery}
434
+ onNavQueryChange={handleNavQueryChange}
435
+ showNavSearch={navSearchEnabled}
436
+ navSearchPlaceholder={navSearchPlaceholder}
437
+ scrollVariant={siderScrollVariant}
438
+ searchVariant={siderSearchVariant}
439
+ />
440
+ </div>
441
+ </Drawer>
442
+ )}
443
+ <Layout
444
+ style={{
445
+ minWidth: 0,
446
+ flex: 1,
447
+ height: "100vh",
448
+ overflow: "hidden",
449
+ display: "flex",
450
+ flexDirection: "column",
451
+ }}
452
+ >
453
+ <Layout.Header
454
+ style={{
455
+ background: token.colorBgContainer,
456
+ paddingInline: token.paddingLG,
457
+ display: "flex",
458
+ alignItems: "center",
459
+ gap: token.marginSM,
460
+ lineHeight: "normal",
461
+ flexShrink: 0,
462
+ }}
463
+ >
464
+ {isMobile && (
465
+ <Button
466
+ type="text"
467
+ icon={<MenuOutlined />}
468
+ onClick={() => setMobileNavOpen(true)}
469
+ aria-label="Open navigation"
470
+ />
471
+ )}
472
+ <div style={{ flex: 1, minWidth: 0 }} />
473
+ {headerExtras}
474
+ <ThemeToolbar />
475
+ <Dropdown
476
+ menu={{
477
+ items: resolvedUserItems,
478
+ onClick: onDropdownMenuClick,
479
+ }}
480
+ trigger={["click"]}
481
+ >
482
+ <Button
483
+ type="text"
484
+ style={{
485
+ display: "inline-flex",
486
+ alignItems: "center",
487
+ gap: token.marginXS,
488
+ maxWidth: isMobile ? 44 : undefined,
489
+ paddingInline: isMobile ? token.paddingXS : undefined,
490
+ }}
491
+ aria-label="Account menu"
492
+ >
493
+ <Avatar size="small" icon={<UserOutlined />} />
494
+ {!isMobile && (
495
+ <Typography.Text
496
+ type="secondary"
497
+ ellipsis
498
+ style={{ maxWidth: 160 }}
499
+ >
500
+ {userLabel}
501
+ </Typography.Text>
502
+ )}
503
+ </Button>
504
+ </Dropdown>
505
+ </Layout.Header>
506
+ <Layout.Content
507
+ style={{
508
+ minWidth: 0,
509
+ flex: 1,
510
+ minHeight: 0,
511
+ display: "flex",
512
+ flexDirection: "column",
513
+ }}
514
+ >
515
+ <ScrollableArea
516
+ ref={contentRef}
517
+ style={{
518
+ margin: isMobile ? token.marginSM : token.marginLG,
519
+ flex: 1,
520
+ minHeight: 0,
521
+ overflow: "auto",
522
+ }}
523
+ >
524
+ <Outlet />
525
+ </ScrollableArea>
526
+ </Layout.Content>
527
+ </Layout>
528
+ </Layout>
529
+ );
530
+ }