ding-react-admin 2.0.0 → 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 (169) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +254 -195
  3. package/dist/index.css +2 -0
  4. package/dist/index.js +1772 -1169
  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/ResourceForm.d.ts +1 -4
  18. package/dist/src/crud/ResourceForm.d.ts.map +1 -1
  19. package/dist/src/crud/ResourceFormModal.d.ts +7 -1
  20. package/dist/src/crud/ResourceFormModal.d.ts.map +1 -1
  21. package/dist/src/crud/ResourceRecordForm.d.ts +27 -0
  22. package/dist/src/crud/ResourceRecordForm.d.ts.map +1 -0
  23. package/dist/src/crud/fields/ReferenceField.d.ts +12 -2
  24. package/dist/src/crud/fields/ReferenceField.d.ts.map +1 -1
  25. package/dist/src/crud/fields/ReferenceInputActions.d.ts +16 -0
  26. package/dist/src/crud/fields/ReferenceInputActions.d.ts.map +1 -0
  27. package/dist/src/crud/fields/ReferenceManyField.d.ts +10 -1
  28. package/dist/src/crud/fields/ReferenceManyField.d.ts.map +1 -1
  29. package/dist/src/crud/filters/ReferenceFilter.d.ts +1 -1
  30. package/dist/src/crud/filters/ReferenceFilter.d.ts.map +1 -1
  31. package/dist/src/crud/index.d.ts +5 -0
  32. package/dist/src/crud/index.d.ts.map +1 -1
  33. package/dist/src/crud/types.d.ts +12 -0
  34. package/dist/src/crud/types.d.ts.map +1 -1
  35. package/dist/src/crud/utils/choiceSelectionUtils.d.ts +10 -0
  36. package/dist/src/crud/utils/choiceSelectionUtils.d.ts.map +1 -0
  37. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts +2 -0
  38. package/dist/src/crud/utils/choiceSelectionUtils.test.d.ts.map +1 -0
  39. package/dist/src/crud/utils/formErrors.d.ts +18 -7
  40. package/dist/src/crud/utils/formErrors.d.ts.map +1 -1
  41. package/dist/src/crud/utils/formErrors.test.d.ts +2 -0
  42. package/dist/src/crud/utils/formErrors.test.d.ts.map +1 -0
  43. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts +1 -0
  44. package/dist/src/crud/utils/prepareFormSubmitBody.d.ts.map +1 -1
  45. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts +4 -0
  46. package/dist/src/crud/utils/referenceSelectNotFoundContent.d.ts.map +1 -0
  47. package/dist/src/crud/utils/toFormData.d.ts +1 -1
  48. package/dist/src/crud/utils/toFormData.d.ts.map +1 -1
  49. package/dist/src/crud/utils/uploadReferenceUtils.d.ts +5 -0
  50. package/dist/src/crud/utils/uploadReferenceUtils.d.ts.map +1 -0
  51. package/dist/src/crud/utils/useChoices.d.ts +27 -1
  52. package/dist/src/crud/utils/useChoices.d.ts.map +1 -1
  53. package/dist/src/crud/utils/useFormRecord.d.ts +9 -7
  54. package/dist/src/crud/utils/useFormRecord.d.ts.map +1 -1
  55. package/dist/src/data/parseFormErrorHelpers.d.ts +6 -27
  56. package/dist/src/data/parseFormErrorHelpers.d.ts.map +1 -1
  57. package/dist/src/index.d.ts +2 -2
  58. package/dist/src/index.d.ts.map +1 -1
  59. package/dist/src/layouts/AdminLayout.d.ts +1 -1
  60. package/dist/src/layouts/AdminLayout.d.ts.map +1 -1
  61. package/dist/src/layouts/AuthPageLayout.d.ts.map +1 -1
  62. package/dist/src/layouts/navFilter.d.ts +5 -0
  63. package/dist/src/layouts/navFilter.d.ts.map +1 -0
  64. package/dist/src/layouts/navMenuItems.d.ts +6 -0
  65. package/dist/src/layouts/navMenuItems.d.ts.map +1 -0
  66. package/dist/src/types.d.ts +12 -0
  67. package/dist/src/types.d.ts.map +1 -1
  68. package/package.json +79 -59
  69. package/src/app/AdminApp.tsx +34 -34
  70. package/src/components/AuthAlternateLink.tsx +23 -23
  71. package/src/components/DensitySwitch.tsx +31 -31
  72. package/src/components/NavMenuLabel.tsx +26 -0
  73. package/src/components/NavMenuSearch.tsx +76 -0
  74. package/src/components/ScrollableArea.tsx +46 -0
  75. package/src/components/ThemeSwitch.tsx +28 -28
  76. package/src/components/ThemeToolbar.tsx +34 -34
  77. package/src/components/index.ts +7 -4
  78. package/src/components/navMenu.css +21 -0
  79. package/src/components/scrollable.css +28 -0
  80. package/src/context/AppThemeProvider.tsx +128 -128
  81. package/src/context/AuthProvider.tsx +89 -77
  82. package/src/context/DataProvider.tsx +30 -30
  83. package/src/context/PermissionsProvider.tsx +57 -57
  84. package/src/crud/FilterBar.tsx +36 -36
  85. package/src/crud/FormGlobalErrorsAlert.tsx +36 -0
  86. package/src/crud/FormSteps.tsx +135 -135
  87. package/src/crud/FormTabs.tsx +126 -126
  88. package/src/crud/InlineFormSet.tsx +162 -162
  89. package/src/crud/ListActionsBar.tsx +106 -106
  90. package/src/crud/ResourceForm.tsx +83 -160
  91. package/src/crud/ResourceFormModal.tsx +75 -133
  92. package/src/crud/ResourceRecordForm.tsx +166 -0
  93. package/src/crud/columns/BooleanColumn.tsx +32 -32
  94. package/src/crud/columns/CustomColumn.tsx +33 -33
  95. package/src/crud/columns/DateColumn.tsx +28 -28
  96. package/src/crud/columns/ImageColumn.tsx +58 -58
  97. package/src/crud/columns/NumberColumn.tsx +31 -31
  98. package/src/crud/columns/ReferenceColumn.tsx +85 -85
  99. package/src/crud/columns/ReferenceManyColumn.tsx +67 -67
  100. package/src/crud/columns/TextColumn.tsx +38 -38
  101. package/src/crud/context/FilterContext.tsx +71 -71
  102. package/src/crud/context/FormContext.tsx +39 -39
  103. package/src/crud/context/FormSectionContext.tsx +22 -22
  104. package/src/crud/context/InlineFieldsRegistry.tsx +61 -61
  105. package/src/crud/context/ListContext.tsx +93 -93
  106. package/src/crud/fields/BooleanField.tsx +38 -38
  107. package/src/crud/fields/DateField.tsx +53 -53
  108. package/src/crud/fields/FileField.tsx +123 -123
  109. package/src/crud/fields/ImageField.tsx +120 -120
  110. package/src/crud/fields/NumberField.tsx +52 -52
  111. package/src/crud/fields/PasswordField.tsx +99 -99
  112. package/src/crud/fields/ReferenceField.tsx +237 -96
  113. package/src/crud/fields/ReferenceInputActions.tsx +106 -0
  114. package/src/crud/fields/ReferenceManyField.tsx +200 -76
  115. package/src/crud/fields/SelectField.tsx +48 -48
  116. package/src/crud/fields/TextField.tsx +46 -46
  117. package/src/crud/fields/uploadFieldUtils.ts +21 -21
  118. package/src/crud/fields/useUploadPreviewUrl.ts +20 -20
  119. package/src/crud/filters/BooleanFilter.tsx +38 -38
  120. package/src/crud/filters/DateFilter.tsx +35 -35
  121. package/src/crud/filters/NumberFilter.tsx +33 -33
  122. package/src/crud/filters/ReferenceFilter.tsx +192 -173
  123. package/src/crud/filters/SelectFilter.tsx +44 -44
  124. package/src/crud/filters/TextFilter.tsx +36 -36
  125. package/src/crud/index.ts +99 -94
  126. package/src/crud/types.ts +146 -134
  127. package/src/crud/utils/buildFormPayload.ts +24 -24
  128. package/src/crud/utils/buildInlineRowsPayload.ts +37 -37
  129. package/src/crud/utils/buildResourceFormSubmitBody.ts +29 -29
  130. package/src/crud/utils/choiceSelectionUtils.test.ts +45 -0
  131. package/src/crud/utils/choiceSelectionUtils.ts +116 -0
  132. package/src/crud/utils/formErrors.test.ts +168 -0
  133. package/src/crud/utils/formErrors.ts +138 -42
  134. package/src/crud/utils/formSectionErrors.ts +85 -85
  135. package/src/crud/utils/getFormValue.ts +13 -13
  136. package/src/crud/utils/hasUploadValues.ts +9 -9
  137. package/src/crud/utils/nestedFieldPath.ts +8 -8
  138. package/src/crud/utils/prepareFormSubmitBody.ts +22 -17
  139. package/src/crud/utils/referenceSelectNotFoundContent.tsx +7 -0
  140. package/src/crud/utils/setFormValue.ts +18 -18
  141. package/src/crud/utils/sortQueryParam.ts +37 -37
  142. package/src/crud/utils/toFormData.ts +79 -81
  143. package/src/crud/utils/uploadReferenceUtils.ts +44 -0
  144. package/src/crud/utils/useAbortableEffect.ts +17 -17
  145. package/src/crud/utils/useChoices.ts +330 -182
  146. package/src/crud/utils/useFormRecord.ts +179 -156
  147. package/src/crud/utils/useListQueryState.ts +191 -191
  148. package/src/data/abortError.test.ts +19 -19
  149. package/src/data/abortError.ts +10 -10
  150. package/src/data/createMemoryResourceHandlers.ts +78 -78
  151. package/src/data/createRestResourceHandlers.ts +63 -63
  152. package/src/data/dataUtils.test.ts +228 -220
  153. package/src/data/inMemoryList.ts +97 -97
  154. package/src/data/parseFormErrorHelpers.test.ts +201 -138
  155. package/src/data/parseFormErrorHelpers.ts +376 -311
  156. package/src/data/sortHelpers.ts +48 -48
  157. package/src/index.ts +211 -207
  158. package/src/layouts/AdminLayout.tsx +530 -382
  159. package/src/layouts/AuthPageLayout.tsx +87 -81
  160. package/src/layouts/navFilter.ts +49 -0
  161. package/src/layouts/navMenuItems.tsx +44 -0
  162. package/src/pages/LoginPage.tsx +80 -80
  163. package/src/pages/PlaceholderPage.tsx +9 -9
  164. package/src/permissions/resourcePermissions.ts +47 -47
  165. package/src/router/createAdminRouter.tsx +64 -64
  166. package/src/router/guards.tsx +63 -63
  167. package/src/router/routeAccess.ts +89 -89
  168. package/src/types.ts +10 -0
  169. package/src/vite-env.d.ts +1 -1
@@ -1,382 +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 style={{ minWidth: 0 }}>
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
- minWidth: 0,
375
- }}
376
- >
377
- <Outlet />
378
- </Layout.Content>
379
- </Layout>
380
- </Layout>
381
- );
382
- }
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
+ }