ui-soxo-bootstrap-core 2.6.40 → 2.6.41
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.
- package/core/lib/components/sidemenu/sidemenu.js +66 -147
- package/core/lib/elements/basic/dragabble-wrapper/draggable-wrapper.js +91 -24
- package/core/lib/models/menus/components/menu-add/menu-add.js +2 -2
- package/core/models/menus/components/menu-add/menu-add.js +31 -4
- package/core/models/menus/components/menu-lists/menu-lists.js +75 -12
- package/core/models/roles/components/role-add/menu-label.js +14 -0
- package/core/models/roles/components/role-add/menu-tree.js +127 -0
- package/core/models/roles/components/role-add/role-add.js +54 -169
- package/core/models/roles/components/role-list/role-list.js +20 -0
- package/core/models/roles/roles.js +3 -0
- package/core/models/users/components/assign-role/assign-role.js +23 -8
- package/package.json +1 -1
|
@@ -385,6 +385,71 @@ export default function SideMenu({ loading, modules = [], callback, appSettings,
|
|
|
385
385
|
|
|
386
386
|
const rootSubmenuKeys = Menus.screenMenus(modules, 'order').map((m) => m.id || m.path || m.caption);
|
|
387
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Recursively renders the menu tree to any depth. Any menu that has visible
|
|
390
|
+
* children is rendered as a <SubMenu>; everything else becomes a clickable
|
|
391
|
+
* leaf <Menu.Item>. This replaces the old hard-coded 3-level rendering so
|
|
392
|
+
* menus nested 4, 5, 6+ levels deep all show up.
|
|
393
|
+
*
|
|
394
|
+
* Keys are kept identical to the previous implementation
|
|
395
|
+
* (SubMenu => id || path || caption, leaf => path || caption) so the
|
|
396
|
+
* openKeys / selectedKeys / onOpenChange logic continues to work.
|
|
397
|
+
*/
|
|
398
|
+
const renderMenuTree = (items, { isRoot = false, parentKey } = {}) => {
|
|
399
|
+
const visibleItems = Menus.screenMenus(items, 'order').filter((record) => {
|
|
400
|
+
// Drop entries without a caption — they have no label to render.
|
|
401
|
+
const hasCaption = typeof record.caption === 'string' && record.caption.trim().length > 0;
|
|
402
|
+
if (!hasCaption) return false;
|
|
403
|
+
|
|
404
|
+
// The visibility / icon rule only applies to top-level menus, matching the
|
|
405
|
+
// previous behaviour; nested items just need a caption.
|
|
406
|
+
if (!isRoot) return true;
|
|
407
|
+
|
|
408
|
+
if (record.id) {
|
|
409
|
+
if (record.is_visible) return true;
|
|
410
|
+
return !!record.icon_name;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return true;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return visibleItems.map((menu, index) => {
|
|
417
|
+
const icon = menu.icon_name || 'fa-solid fas fa-user';
|
|
418
|
+
|
|
419
|
+
// Children that actually have something to show (a caption).
|
|
420
|
+
const children =
|
|
421
|
+
menu && menu.sub_menus
|
|
422
|
+
? Menus.screenMenus(menu.sub_menus, 'order').filter((s) => typeof s.caption === 'string' && s.caption.trim().length > 0)
|
|
423
|
+
: [];
|
|
424
|
+
|
|
425
|
+
if (children.length) {
|
|
426
|
+
return (
|
|
427
|
+
<SubMenu
|
|
428
|
+
className="popup"
|
|
429
|
+
style={{ color: state.theme.colors.leftSectionColor }}
|
|
430
|
+
key={menu.id || menu.path || menu.caption}
|
|
431
|
+
title={
|
|
432
|
+
<span>
|
|
433
|
+
<CollapsedIconMenu menu={menu} caption={menu.caption} icon={icon} collapsed={collapsed} />
|
|
434
|
+
</span>
|
|
435
|
+
}
|
|
436
|
+
>
|
|
437
|
+
{renderMenuTree(menu.sub_menus, { isRoot: false, parentKey: menu.path || menu.caption })}
|
|
438
|
+
</SubMenu>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return (
|
|
443
|
+
<Menu.Item
|
|
444
|
+
key={menu.path || menu.caption}
|
|
445
|
+
onClick={() => onMenuClick({ ...menu, parentKey: parentKey || menu.path || menu.caption, isRoot }, index)}
|
|
446
|
+
>
|
|
447
|
+
<CollapsedIconMenu menu={menu} caption={menu.caption} icon={icon} collapsed={collapsed} />
|
|
448
|
+
</Menu.Item>
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
};
|
|
452
|
+
|
|
388
453
|
return (
|
|
389
454
|
<div className="sidemenu">
|
|
390
455
|
{loading ? (
|
|
@@ -490,153 +555,7 @@ export default function SideMenu({ loading, modules = [], callback, appSettings,
|
|
|
490
555
|
})
|
|
491
556
|
} */}
|
|
492
557
|
|
|
493
|
-
{
|
|
494
|
-
|
|
495
|
-
.filter((record) => {
|
|
496
|
-
icon = record;
|
|
497
|
-
|
|
498
|
-
// Drop entries without a caption — they have no permission/label
|
|
499
|
-
// to render, so showing just an icon is misleading.
|
|
500
|
-
const hasCaption = typeof record.caption === 'string' && record.caption.trim().length > 0;
|
|
501
|
-
if (!hasCaption) return false;
|
|
502
|
-
|
|
503
|
-
if (record.id) {
|
|
504
|
-
if (record.is_visible) {
|
|
505
|
-
return true;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
if (record.icon_name) {
|
|
509
|
-
return true;
|
|
510
|
-
} else {
|
|
511
|
-
return false;
|
|
512
|
-
}
|
|
513
|
-
} else {
|
|
514
|
-
return true;
|
|
515
|
-
}
|
|
516
|
-
})
|
|
517
|
-
.map((menu, index) => {
|
|
518
|
-
// return <MenuItem menu={menu} index={index} />
|
|
519
|
-
|
|
520
|
-
let sub_menus = menu && menu.sub_menus
|
|
521
|
-
? Menus.screenMenus(menu.sub_menus).filter((s) => typeof s.caption === 'string' && s.caption.trim().length > 0)
|
|
522
|
-
: [];
|
|
523
|
-
|
|
524
|
-
if (menu && sub_menus && sub_menus.length) {
|
|
525
|
-
// let randomIndex = parseInt(Math.random() * 10000000000);
|
|
526
|
-
|
|
527
|
-
return (
|
|
528
|
-
<SubMenu
|
|
529
|
-
className="popup"
|
|
530
|
-
style={{ color: state.theme.colors.leftSectionColor }}
|
|
531
|
-
// key={`first-level-${randomIndex}-${menu.caption}`}
|
|
532
|
-
|
|
533
|
-
key={menu.id || menu.path || menu.caption}
|
|
534
|
-
title={
|
|
535
|
-
<>
|
|
536
|
-
<CollapsedIconMenu
|
|
537
|
-
menu={menu}
|
|
538
|
-
caption={menu.caption}
|
|
539
|
-
icon={menu.icon_name || 'fa-solid fas fa-user'}
|
|
540
|
-
collapsed={collapsed}
|
|
541
|
-
/>
|
|
542
|
-
</>
|
|
543
|
-
}
|
|
544
|
-
>
|
|
545
|
-
{sub_menus.map((submenu, innerIndex) => {
|
|
546
|
-
// let randomIndex = parseInt(Math.random() * 10000000000);
|
|
547
|
-
|
|
548
|
-
let third_menus = submenu && submenu.sub_menus ? Menus.screenMenus(submenu.sub_menus) : [];
|
|
549
|
-
|
|
550
|
-
if (third_menus && third_menus.length) {
|
|
551
|
-
return (
|
|
552
|
-
<SubMenu
|
|
553
|
-
className="popup"
|
|
554
|
-
// key={`second-level-${randomIndex}-${submenu.id}`}
|
|
555
|
-
|
|
556
|
-
key={submenu.id || submenu.path || submenu.caption}
|
|
557
|
-
title={
|
|
558
|
-
<span>
|
|
559
|
-
<CollapsedIconMenu
|
|
560
|
-
menu={menu}
|
|
561
|
-
caption={submenu.caption}
|
|
562
|
-
icon={submenu.icon_name || 'fa-solid fas fa-user'}
|
|
563
|
-
collapsed={collapsed}
|
|
564
|
-
/>
|
|
565
|
-
</span>
|
|
566
|
-
}
|
|
567
|
-
>
|
|
568
|
-
{third_menus.map((menu) => {
|
|
569
|
-
// let randomIndex = parseInt(Math.random() * 10000000000);
|
|
570
|
-
|
|
571
|
-
return (
|
|
572
|
-
<Menu.Item
|
|
573
|
-
// onClick={() => {
|
|
574
|
-
// onMenuClick(menu, index);
|
|
575
|
-
// }}
|
|
576
|
-
onClick={() => {
|
|
577
|
-
onMenuClick({ ...menu, parentKey: submenu.path || submenu.caption }, index);
|
|
578
|
-
}}
|
|
579
|
-
// key={`second-level-${randomIndex}-${index}`}
|
|
580
|
-
|
|
581
|
-
key={menu.path || menu.caption}
|
|
582
|
-
>
|
|
583
|
-
<CollapsedIconMenu
|
|
584
|
-
menu={menu}
|
|
585
|
-
caption={menu.caption}
|
|
586
|
-
icon={menu.icon_name || 'fa-solid fas fa-user'}
|
|
587
|
-
collapsed={collapsed}
|
|
588
|
-
/>
|
|
589
|
-
</Menu.Item>
|
|
590
|
-
);
|
|
591
|
-
})}
|
|
592
|
-
</SubMenu>
|
|
593
|
-
);
|
|
594
|
-
} else {
|
|
595
|
-
// let randomIndex = parseInt(Math.random() * 10000000000);
|
|
596
|
-
|
|
597
|
-
return (
|
|
598
|
-
<Menu.Item
|
|
599
|
-
// onClick={() => {
|
|
600
|
-
// onMenuClick(submenu, index);
|
|
601
|
-
// }}
|
|
602
|
-
onClick={() => {
|
|
603
|
-
onMenuClick({ ...submenu, parentKey: menu.path || menu.caption }, index);
|
|
604
|
-
}}
|
|
605
|
-
// key={`first-level-${randomIndex}-${innerIndex}`}
|
|
606
|
-
key={submenu.path || submenu.caption}
|
|
607
|
-
>
|
|
608
|
-
<CollapsedIconMenu
|
|
609
|
-
menu={menu}
|
|
610
|
-
caption={submenu.caption}
|
|
611
|
-
icon={submenu.icon_name || 'fa-solid fas fa-user'}
|
|
612
|
-
collapsed={collapsed}
|
|
613
|
-
/>
|
|
614
|
-
</Menu.Item>
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
|
-
})}
|
|
618
|
-
</SubMenu>
|
|
619
|
-
);
|
|
620
|
-
} else {
|
|
621
|
-
// let randomIndex = parseInt(Math.random() * 10000000000);
|
|
622
|
-
|
|
623
|
-
return (
|
|
624
|
-
<Menu.Item
|
|
625
|
-
// onClick={() => {
|
|
626
|
-
// onMenuClick(menu, index);
|
|
627
|
-
// }}
|
|
628
|
-
|
|
629
|
-
onClick={() => {
|
|
630
|
-
onMenuClick({ ...menu, parentKey: menu.path || menu.caption, isRoot: true }, index);
|
|
631
|
-
}}
|
|
632
|
-
// key={`${menu.id}-${randomIndex}`}
|
|
633
|
-
key={menu.path || menu.caption}
|
|
634
|
-
>
|
|
635
|
-
<CollapsedIconMenu menu={menu} caption={menu.caption} icon={menu.icon_name || 'fa-solid fas fa-user'} collapsed={collapsed} />
|
|
636
|
-
</Menu.Item>
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
})}
|
|
558
|
+
{renderMenuTree(modules, { isRoot: true })}
|
|
640
559
|
|
|
641
560
|
{loading ? (
|
|
642
561
|
<div class="skeleton-wrapper"></div>
|
|
@@ -1,26 +1,27 @@
|
|
|
1
|
-
import React, { useRef } from 'react';
|
|
1
|
+
import React, { useRef, useEffect } from 'react';
|
|
2
2
|
import { useDrag, useDrop } from 'react-dnd';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
window.scrollBy(0, -SPEED);
|
|
17
|
-
}
|
|
4
|
+
// Walk up the DOM to find the nearest vertically-scrollable ancestor.
|
|
5
|
+
// Returns null when the page (window) is the scroller.
|
|
6
|
+
function getScrollableAncestor(node) {
|
|
7
|
+
let el = node?.parentElement;
|
|
8
|
+
while (el) {
|
|
9
|
+
const { overflowY } = window.getComputedStyle(el);
|
|
10
|
+
const scrollable = overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay';
|
|
11
|
+
if (scrollable && el.scrollHeight > el.clientHeight) return el;
|
|
12
|
+
el = el.parentElement;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
export default function DraggableWrapper({ id, index, movePanel, item, dragEnabled, level, parentId, onCrossLevelMove, canAcceptChildren }) {
|
|
18
|
+
// Root DOM node, used to locate the scrollable ancestor at drag time
|
|
19
|
+
const rootRef = useRef(null);
|
|
20
|
+
// Latest pointer Y, fed by a document-level `dragover` listener (see effect below)
|
|
21
|
+
const pointerY = useRef(0);
|
|
22
|
+
const rafRef = useRef(null);
|
|
23
|
+
// The scrollable container to auto-scroll (resolved when a drag starts)
|
|
24
|
+
const scrollContainerRef = useRef(null);
|
|
24
25
|
|
|
25
26
|
const [{ isDragging }, drag] = useDrag({
|
|
26
27
|
type: 'PANEL',
|
|
@@ -31,12 +32,78 @@ export default function DraggableWrapper({ id, index, movePanel, item, dragEnabl
|
|
|
31
32
|
}),
|
|
32
33
|
});
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Continuous edge auto-scroll while THIS item is being dragged.
|
|
37
|
+
*
|
|
38
|
+
* Driven by a document-level `dragover` listener + requestAnimationFrame loop
|
|
39
|
+
* rather than the drop target's `hover` handler. `hover` only fires while the
|
|
40
|
+
* pointer is over a droppable panel and only on movement, so scrolling UP
|
|
41
|
+
* failed: the top of the viewport is the (non-droppable) header, and holding
|
|
42
|
+
* the pointer still at an edge produced no events. The rAF loop keeps
|
|
43
|
+
* scrolling from the last known pointer position regardless of what's beneath.
|
|
44
|
+
*
|
|
45
|
+
* The list may scroll inside an overflow container rather than the window, so
|
|
46
|
+
* we resolve the nearest scrollable ancestor and scroll that (falling back to
|
|
47
|
+
* the window), computing the edges from the container's own bounds.
|
|
48
|
+
*/
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!isDragging) return;
|
|
51
|
+
|
|
52
|
+
const EDGE = 80;
|
|
53
|
+
const SPEED = 20;
|
|
54
|
+
|
|
55
|
+
scrollContainerRef.current = getScrollableAncestor(rootRef.current);
|
|
56
|
+
|
|
57
|
+
const onDragOver = (e) => {
|
|
58
|
+
pointerY.current = e.clientY;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const tick = () => {
|
|
62
|
+
const y = pointerY.current;
|
|
63
|
+
|
|
64
|
+
if (y > 0) {
|
|
65
|
+
const container = scrollContainerRef.current;
|
|
66
|
+
|
|
67
|
+
if (container) {
|
|
68
|
+
const rect = container.getBoundingClientRect();
|
|
69
|
+
// near container top
|
|
70
|
+
if (y < rect.top + EDGE) {
|
|
71
|
+
container.scrollTop -= SPEED;
|
|
72
|
+
}
|
|
73
|
+
// near container bottom
|
|
74
|
+
else if (y > rect.bottom - EDGE) {
|
|
75
|
+
container.scrollTop += SPEED;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
const viewportHeight = window.innerHeight;
|
|
79
|
+
// near viewport top
|
|
80
|
+
if (y < EDGE) {
|
|
81
|
+
window.scrollBy(0, -SPEED);
|
|
82
|
+
}
|
|
83
|
+
// near viewport bottom
|
|
84
|
+
else if (y > viewportHeight - EDGE) {
|
|
85
|
+
window.scrollBy(0, SPEED);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
window.addEventListener('dragover', onDragOver);
|
|
94
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
window.removeEventListener('dragover', onDragOver);
|
|
98
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
99
|
+
pointerY.current = 0;
|
|
100
|
+
scrollContainerRef.current = null;
|
|
101
|
+
};
|
|
102
|
+
}, [isDragging]);
|
|
103
|
+
|
|
34
104
|
const [{ isOver, canDrop }, drop] = useDrop({
|
|
35
105
|
accept: 'PANEL',
|
|
36
106
|
hover: (dragItem, monitor) => {
|
|
37
|
-
// THIS FIXES BOTTOM → TOP
|
|
38
|
-
autoScrollWindow(monitor);
|
|
39
|
-
|
|
40
107
|
if (dragItem.index === index) return;
|
|
41
108
|
|
|
42
109
|
if (dragItem.level === level && dragItem.parentId === parentId) {
|
|
@@ -85,7 +152,7 @@ export default function DraggableWrapper({ id, index, movePanel, item, dragEnabl
|
|
|
85
152
|
const childZoneBackgroundColor = isOverChild && canDropChild ? '#d4f4dd' : 'transparent';
|
|
86
153
|
|
|
87
154
|
return (
|
|
88
|
-
<div style={{ width: '100%', display: 'flex' }}>
|
|
155
|
+
<div ref={rootRef} style={{ width: '100%', display: 'flex' }}>
|
|
89
156
|
{/* HEADER DROP — reorder only */}
|
|
90
157
|
<div ref={drop}>
|
|
91
158
|
<div
|
|
@@ -158,7 +158,7 @@ export default function MenuAdd({ model, edit, history, match, formContent, call
|
|
|
158
158
|
|
|
159
159
|
{/* Model */}
|
|
160
160
|
<Form.Item label="Model" name="model_id">
|
|
161
|
-
<Select style={{ width: '100%' }}>
|
|
161
|
+
<Select allowClear style={{ width: '100%' }}>
|
|
162
162
|
{models.map((model, key) => (
|
|
163
163
|
<Option key={key} value={model.id}>{model.name}</Option>
|
|
164
164
|
))}
|
|
@@ -168,7 +168,7 @@ export default function MenuAdd({ model, edit, history, match, formContent, call
|
|
|
168
168
|
|
|
169
169
|
{/* Page */}
|
|
170
170
|
<Form.Item label="Page" name="page_id">
|
|
171
|
-
<Select style={{ width: '100%' }}>
|
|
171
|
+
<Select allowClear style={{ width: '100%' }}>
|
|
172
172
|
{pages.map((page, key) => (
|
|
173
173
|
<Option key={key} value={page.id}>{page.name}</Option>
|
|
174
174
|
))}
|
|
@@ -116,6 +116,13 @@ const MenuAdd = ({ model, callback, edit, history, formContent, match, additiona
|
|
|
116
116
|
values.step = formContent.step || step;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// Cleared selects come back as undefined; send null so the backend clears them
|
|
120
|
+
['model_id', 'page_id', 'header_id'].forEach((field) => {
|
|
121
|
+
if (values[field] === undefined) {
|
|
122
|
+
values[field] = null;
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
119
126
|
if (values.attributes && typeof values === 'object') {
|
|
120
127
|
values = {
|
|
121
128
|
...values,
|
|
@@ -183,7 +190,7 @@ const MenuAdd = ({ model, callback, edit, history, formContent, match, additiona
|
|
|
183
190
|
{/* Model */}
|
|
184
191
|
<Form.Item label="Models" name={'model_id'}>
|
|
185
192
|
{/* <ReferenceSelect value={model.id} label={model.name} model={Models} /> */}
|
|
186
|
-
<Select showSearch style={{ width: '100%' }} placeholder="Select a Page" optionFilterProp="label">
|
|
193
|
+
<Select showSearch style={{ width: '100%' }} placeholder="Select a Page" optionFilterProp="label" allowClear>
|
|
187
194
|
{models.map((model, key) => (
|
|
188
195
|
<Option key={key} label={model.name} value={model.id}>
|
|
189
196
|
{model.name}
|
|
@@ -198,7 +205,7 @@ const MenuAdd = ({ model, callback, edit, history, formContent, match, additiona
|
|
|
198
205
|
<Form.Item label="Pages" name={'page_id'}>
|
|
199
206
|
{/* <ReferenceSelect value={pages.id} label={pages.name} model={Pages} /> */}
|
|
200
207
|
|
|
201
|
-
<Select showSearch optionFilterProp="children" style={{ width: '100%' }}>
|
|
208
|
+
<Select showSearch optionFilterProp="children" style={{ width: '100%' }} allowClear>
|
|
202
209
|
{pages.map((model, key) => (
|
|
203
210
|
<Option key={key} value={model.id}>
|
|
204
211
|
{model.name}
|
|
@@ -229,13 +236,33 @@ const MenuAdd = ({ model, callback, edit, history, formContent, match, additiona
|
|
|
229
236
|
{/* Pages Ends */}
|
|
230
237
|
|
|
231
238
|
{/* Path */}
|
|
232
|
-
<Form.Item
|
|
239
|
+
<Form.Item
|
|
240
|
+
name="path"
|
|
241
|
+
label="Path"
|
|
242
|
+
rules={[
|
|
243
|
+
{ required: true, message: 'Path is required' },
|
|
244
|
+
{
|
|
245
|
+
pattern: /^\//,
|
|
246
|
+
message: 'Path must start with /',
|
|
247
|
+
},
|
|
248
|
+
]}
|
|
249
|
+
>
|
|
233
250
|
<Input placeholder="Enter path" />
|
|
234
251
|
</Form.Item>
|
|
235
252
|
{/* Path Ends */}
|
|
236
253
|
|
|
237
254
|
{/* Route */}
|
|
238
|
-
<Form.Item
|
|
255
|
+
<Form.Item
|
|
256
|
+
name="route"
|
|
257
|
+
label="Route"
|
|
258
|
+
rules={[
|
|
259
|
+
{ required: false, message: 'Route is required' },
|
|
260
|
+
{
|
|
261
|
+
pattern: /^\//,
|
|
262
|
+
message: 'Route must start with /',
|
|
263
|
+
},
|
|
264
|
+
]}
|
|
265
|
+
>
|
|
239
266
|
<Input placeholder="Enter route" />
|
|
240
267
|
</Form.Item>
|
|
241
268
|
{/* Route Ends */}
|
|
@@ -30,6 +30,9 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
30
30
|
const [query, setQuery] = useState('');
|
|
31
31
|
const [dragMode, setDragMode] = useState(false);
|
|
32
32
|
const [orderChanged, setOrderChanged] = useState(false);
|
|
33
|
+
// keys of currently-expanded top-level panels. Seeded from the search results
|
|
34
|
+
// when the query changes, but the user can freely toggle panels afterwards.
|
|
35
|
+
const [openKeys, setOpenKeys] = useState([]);
|
|
33
36
|
|
|
34
37
|
const [nextId, setNextId] = useState(10000);
|
|
35
38
|
|
|
@@ -181,6 +184,16 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
181
184
|
return;
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
// Prevent moving a menu into one of its own descendants
|
|
188
|
+
// (e.g. A > B, C — A cannot be dropped under B or C).
|
|
189
|
+
if (targetParentId) {
|
|
190
|
+
const draggedNode = findMenuById(records, draggedItem.id);
|
|
191
|
+
if (draggedNode && findMenuById(draggedNode.sub_menus || [], targetParentId)) {
|
|
192
|
+
message.warning('Cannot move a menu into its own sub menu');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
184
197
|
// Remove item from original location
|
|
185
198
|
const { newItems: itemsAfterRemove, foundItem } = findAndRemoveItem(records, draggedItem.id);
|
|
186
199
|
|
|
@@ -194,7 +207,7 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
194
207
|
|
|
195
208
|
setRecords(finalItems);
|
|
196
209
|
setOrderChanged(true);
|
|
197
|
-
message.success(`Moved "${foundItem.
|
|
210
|
+
message.success(`Moved "${foundItem.caption}" to level ${targetLevel}`);
|
|
198
211
|
},
|
|
199
212
|
[records]
|
|
200
213
|
);
|
|
@@ -203,9 +216,42 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
203
216
|
model.delete(rec).then(loadMenus);
|
|
204
217
|
};
|
|
205
218
|
|
|
206
|
-
|
|
219
|
+
// Recursively filter the menu tree by caption. A menu is kept if its own
|
|
220
|
+
// caption matches OR any descendant matches. When the menu itself matches we
|
|
221
|
+
// keep its full subtree; when only a descendant matches we keep just the
|
|
222
|
+
// pruned path so the matching item stays reachable.
|
|
223
|
+
const filterMenus = (menus, q) => {
|
|
224
|
+
if (!q) return menus || [];
|
|
225
|
+
const lower = q.toLowerCase();
|
|
226
|
+
|
|
227
|
+
return (menus || []).reduce((acc, menu) => {
|
|
228
|
+
const selfMatch = (menu.caption || '').toLowerCase().includes(lower);
|
|
229
|
+
if (selfMatch) {
|
|
230
|
+
acc.push({ ...menu });
|
|
231
|
+
return acc;
|
|
232
|
+
}
|
|
233
|
+
const subFiltered = filterMenus(menu.sub_menus || [], q);
|
|
234
|
+
if (subFiltered.length) {
|
|
235
|
+
acc.push({ ...menu, sub_menus: subFiltered });
|
|
236
|
+
}
|
|
237
|
+
return acc;
|
|
238
|
+
}, []);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Ids of items (at one level) that have sub_menus — used to auto-expand
|
|
242
|
+
// every branch leading to a search match.
|
|
243
|
+
const expandableKeys = (items) => (items || []).filter((m) => m.sub_menus && m.sub_menus.length > 0).map((m) => String(m.id));
|
|
207
244
|
|
|
208
|
-
const
|
|
245
|
+
const searchActive = !!query;
|
|
246
|
+
const visibleItems = filterMenus(records, query);
|
|
247
|
+
|
|
248
|
+
// When the query changes, seed the open panels from the search results (every
|
|
249
|
+
// branch on the path to a match) so the matched sub-menu is revealed. After
|
|
250
|
+
// that the user can collapse/expand freely via the Collapse onChange below.
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
setOpenKeys(query ? expandableKeys(filterMenus(records, query)) : []);
|
|
253
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
254
|
+
}, [query]);
|
|
209
255
|
|
|
210
256
|
const onSearch = (event) => {
|
|
211
257
|
setQuery(event.target.value);
|
|
@@ -305,12 +351,12 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
305
351
|
<Card className="generic-list">
|
|
306
352
|
<div style={{ marginBottom: 16 }}>
|
|
307
353
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
308
|
-
<Search placeholder="
|
|
354
|
+
<Search placeholder="Search menus & sub-menus…" allowClear style={{ width: 300, marginBottom: '0px' }} onChange={onSearch} />
|
|
309
355
|
|
|
310
356
|
<Space size="small">
|
|
311
|
-
<Button onClick={getRecords} size={'small'} type="default">
|
|
357
|
+
{/* <Button onClick={getRecords} size={'small'} type="default">
|
|
312
358
|
<ReloadOutlined />
|
|
313
|
-
</Button>
|
|
359
|
+
</Button> */}
|
|
314
360
|
|
|
315
361
|
<Switch checked={dragMode} onChange={toggleDragMode} checkedChildren="Order On" unCheckedChildren="Order Off" />
|
|
316
362
|
|
|
@@ -361,7 +407,11 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
361
407
|
{/* {!view ? ( */}
|
|
362
408
|
<Card>
|
|
363
409
|
<DndProvider backend={HTML5Backend}>
|
|
364
|
-
<Collapse
|
|
410
|
+
<Collapse
|
|
411
|
+
accordion={!searchActive}
|
|
412
|
+
activeKey={openKeys}
|
|
413
|
+
onChange={(keys) => setOpenKeys(Array.isArray(keys) ? keys : keys ? [keys] : [])}
|
|
414
|
+
>
|
|
365
415
|
{visibleItems && visibleItems.length > 0 ? (
|
|
366
416
|
visibleItems.map((item, index) => (
|
|
367
417
|
<Panel
|
|
@@ -389,6 +439,8 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
389
439
|
items={item.sub_menus || []}
|
|
390
440
|
model={model}
|
|
391
441
|
dragMode={dragMode}
|
|
442
|
+
searchActive={searchActive}
|
|
443
|
+
query={query}
|
|
392
444
|
setSelectedRecord={setSelectedRecord}
|
|
393
445
|
setDrawerTitle={setDrawerTitle}
|
|
394
446
|
setDrawerVisible={setDrawerVisible}
|
|
@@ -437,7 +489,7 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
437
489
|
);
|
|
438
490
|
};
|
|
439
491
|
/* -----------------------------------------------------------------------
|
|
440
|
-
PANEL ACTIONS
|
|
492
|
+
PANEL ACTIONS
|
|
441
493
|
------------------------------------------------------------------------ */
|
|
442
494
|
function panelActions(item, model, setSelectedRecord, setDrawerTitle, setDrawerVisible, deleteRecord) {
|
|
443
495
|
return (
|
|
@@ -500,6 +552,8 @@ function NestedMenu({
|
|
|
500
552
|
items,
|
|
501
553
|
model,
|
|
502
554
|
dragMode,
|
|
555
|
+
searchActive,
|
|
556
|
+
query,
|
|
503
557
|
setSelectedRecord,
|
|
504
558
|
setDrawerTitle,
|
|
505
559
|
setDrawerVisible,
|
|
@@ -508,15 +562,22 @@ function NestedMenu({
|
|
|
508
562
|
onCrossLevelMove,
|
|
509
563
|
onChange,
|
|
510
564
|
}) {
|
|
511
|
-
// do not render Collapse
|
|
512
|
-
if (!items || items.length === 0) return null;
|
|
513
|
-
|
|
514
565
|
const [localItems, setLocalItems] = useState(items);
|
|
566
|
+
// keys of currently-expanded sub-panels. Seeded from the search results when
|
|
567
|
+
// the query changes, but the user can freely toggle panels afterwards.
|
|
568
|
+
const [openKeys, setOpenKeys] = useState([]);
|
|
515
569
|
|
|
516
570
|
useEffect(() => {
|
|
517
571
|
setLocalItems(items);
|
|
518
572
|
}, [items]);
|
|
519
573
|
|
|
574
|
+
// Seed the open sub-panels from the search path when the query changes; keep
|
|
575
|
+
// the user's toggles otherwise so a search-opened panel can be closed.
|
|
576
|
+
useEffect(() => {
|
|
577
|
+
setOpenKeys(searchActive ? (items || []).filter((c) => c.sub_menus && c.sub_menus.length > 0).map((c) => String(c.id)) : []);
|
|
578
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
579
|
+
}, [query, searchActive]);
|
|
580
|
+
|
|
520
581
|
const moveSubMenu = useCallback(
|
|
521
582
|
(from, to) => {
|
|
522
583
|
if (!dragMode || from === to) return;
|
|
@@ -536,7 +597,7 @@ function NestedMenu({
|
|
|
536
597
|
}
|
|
537
598
|
|
|
538
599
|
return (
|
|
539
|
-
<Collapse accordion>
|
|
600
|
+
<Collapse accordion={!searchActive} activeKey={openKeys} onChange={(keys) => setOpenKeys(Array.isArray(keys) ? keys : keys ? [keys] : [])}>
|
|
540
601
|
{localItems.map((child, index) => (
|
|
541
602
|
<Panel
|
|
542
603
|
key={child.id}
|
|
@@ -563,6 +624,8 @@ function NestedMenu({
|
|
|
563
624
|
step={step + 1}
|
|
564
625
|
items={child.sub_menus || []}
|
|
565
626
|
dragMode={dragMode}
|
|
627
|
+
searchActive={searchActive}
|
|
628
|
+
query={query}
|
|
566
629
|
setSelectedRecord={setSelectedRecord}
|
|
567
630
|
setDrawerTitle={setDrawerTitle}
|
|
568
631
|
setDrawerVisible={setDrawerVisible}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// ------------------------
|
|
2
|
+
// Menu caption + path label
|
|
3
|
+
// ------------------------
|
|
4
|
+
const MenuLabel = ({ menu }) => {
|
|
5
|
+
const path = menu.path || menu.route;
|
|
6
|
+
return (
|
|
7
|
+
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 8, lineHeight: 1.3 }}>
|
|
8
|
+
<span>{menu.caption}</span>
|
|
9
|
+
{path ? <span style={{ color: '#999', fontSize: 12 }}>{path}</span> : null}
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default MenuLabel;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { Collapse, Checkbox, Tag } from 'antd';
|
|
2
|
+
|
|
3
|
+
import MenuLabel from './menu-label';
|
|
4
|
+
|
|
5
|
+
const { Panel } = Collapse;
|
|
6
|
+
|
|
7
|
+
// ------------------------
|
|
8
|
+
// Recursive Nested Menu Component
|
|
9
|
+
// ------------------------
|
|
10
|
+
const MenuTree = ({ menus, selectedMenus, toggleMenu, parentId = null, searchActive = false }) => {
|
|
11
|
+
// Helper: check if parent should be checked
|
|
12
|
+
const isParentChecked = (menu) => {
|
|
13
|
+
if (!menu.sub_menus || menu.sub_menus.length === 0) {
|
|
14
|
+
return selectedMenus.includes(menu.id);
|
|
15
|
+
}
|
|
16
|
+
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
17
|
+
return allChildIds.every((id) => selectedMenus.includes(id));
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Helper: check if parent is indeterminate
|
|
21
|
+
const isParentIndeterminate = (menu) => {
|
|
22
|
+
if (!menu.sub_menus || menu.sub_menus.length === 0) return false;
|
|
23
|
+
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
24
|
+
const checkedCount = allChildIds.filter((id) => selectedMenus.includes(id)).length;
|
|
25
|
+
return checkedCount > 0 && checkedCount < allChildIds.length;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<>
|
|
30
|
+
{menus.map((menu) => {
|
|
31
|
+
const children = menu.sub_menus || [];
|
|
32
|
+
const parentChecked = isParentChecked(menu);
|
|
33
|
+
const parentIndeterminate = isParentIndeterminate(menu);
|
|
34
|
+
|
|
35
|
+
const onParentChange = (checked) => {
|
|
36
|
+
toggleMenu(menu.id, checked);
|
|
37
|
+
// toggle children recursively
|
|
38
|
+
children.forEach((c) => toggleMenuRecursive(c, checked));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const toggleMenuRecursive = (menu, checked) => {
|
|
42
|
+
toggleMenu(menu.id, checked);
|
|
43
|
+
if (menu.sub_menus && menu.sub_menus.length > 0) {
|
|
44
|
+
menu.sub_menus.forEach((c) => toggleMenuRecursive(c, checked));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (children.length === 0) {
|
|
49
|
+
return (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
justifyContent: 'space-between',
|
|
53
|
+
display: 'flex',
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
border: '1px solid rgba(198, 195, 195, 0.85)',
|
|
56
|
+
// borderRadius: 6,
|
|
57
|
+
padding: '12px 16px',
|
|
58
|
+
marginBottom: 6,
|
|
59
|
+
background: '#fff',
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
key={menu.id}
|
|
64
|
+
style={{
|
|
65
|
+
display: 'flex',
|
|
66
|
+
alignItems: 'center',
|
|
67
|
+
gap: 8,
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
<Checkbox
|
|
71
|
+
checked={selectedMenus.includes(menu.id)}
|
|
72
|
+
onChange={(e) => {
|
|
73
|
+
const checked = e.target.checked;
|
|
74
|
+
|
|
75
|
+
toggleMenu(menu.id, checked);
|
|
76
|
+
|
|
77
|
+
// FORCE parent selection
|
|
78
|
+
if (checked && parentId) {
|
|
79
|
+
toggleMenu(parentId, true);
|
|
80
|
+
}
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
<MenuLabel menu={menu} />
|
|
84
|
+
</div>
|
|
85
|
+
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<Collapse
|
|
92
|
+
// remount when search toggles so panels open while searching and collapse when cleared
|
|
93
|
+
key={`${menu.id}-${searchActive}`}
|
|
94
|
+
style={{ marginBottom: 6 }}
|
|
95
|
+
defaultActiveKey={searchActive ? [menu.id] : undefined}
|
|
96
|
+
>
|
|
97
|
+
<Panel
|
|
98
|
+
key={menu.id}
|
|
99
|
+
header={
|
|
100
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
101
|
+
<div
|
|
102
|
+
style={{
|
|
103
|
+
display: 'flex',
|
|
104
|
+
alignItems: 'center',
|
|
105
|
+
gap: 8,
|
|
106
|
+
}}
|
|
107
|
+
onClick={(e) => e.stopPropagation()}
|
|
108
|
+
>
|
|
109
|
+
<Checkbox checked={parentChecked} indeterminate={parentIndeterminate} onChange={(e) => onParentChange(e.target.checked)} />
|
|
110
|
+
<MenuLabel menu={menu} />
|
|
111
|
+
</div>
|
|
112
|
+
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
113
|
+
</div>
|
|
114
|
+
}
|
|
115
|
+
>
|
|
116
|
+
<div style={{ paddingLeft: 20 }}>
|
|
117
|
+
<MenuTree menus={children} selectedMenus={selectedMenus} toggleMenu={toggleMenu} parentId={menu.id} searchActive={searchActive} />
|
|
118
|
+
</div>
|
|
119
|
+
</Panel>
|
|
120
|
+
</Collapse>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</>
|
|
124
|
+
);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export default MenuTree;
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import React, { useState, useEffect, useContext } from 'react';
|
|
2
2
|
import { useLocation, useParams } from 'react-router-dom';
|
|
3
|
-
import { Skeleton, Typography, message, Form, Input
|
|
3
|
+
import { Skeleton, Typography, message, Form, Input } from 'antd';
|
|
4
4
|
import { GlobalContext } from './../../../../lib';
|
|
5
|
-
import { Button } from './../../../../lib';
|
|
6
5
|
import { ModelsAPI, PagesAPI, RolesAPI, MenusAPI } from '../../..';
|
|
6
|
+
import MenuTree from './menu-tree';
|
|
7
7
|
import './role-add.scss';
|
|
8
8
|
|
|
9
9
|
const { Title } = Typography;
|
|
10
|
-
const { Panel } = Collapse;
|
|
11
10
|
|
|
12
|
-
const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [] }) => {
|
|
11
|
+
const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_queries = [], onSubmittingChange, formRef }) => {
|
|
13
12
|
let mode = 'Add';
|
|
14
13
|
if (formContent.id) mode = 'Edit';
|
|
15
14
|
else if (formContent.copy) mode = 'copy';
|
|
@@ -31,8 +30,10 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
31
30
|
|
|
32
31
|
const isEdit = !!(formContent.id || formContent.copy);
|
|
33
32
|
const [initialLoading, setInitialLoading] = useState(isEdit);
|
|
34
|
-
const [loading, setLoading] = useState(false);
|
|
35
33
|
const [form] = Form.useForm();
|
|
34
|
+
|
|
35
|
+
// expose the form instance so the Drawer footer Save button can submit it
|
|
36
|
+
if (formRef) formRef.current = form;
|
|
36
37
|
const [pages, setPages] = useState([]);
|
|
37
38
|
const [models, setModels] = useState([]);
|
|
38
39
|
const [menuList, setMenuList] = useState([]);
|
|
@@ -43,6 +44,9 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
43
44
|
// Selected menus (array of IDs)
|
|
44
45
|
const [selectedMenus, setSelectedMenus] = useState([]);
|
|
45
46
|
|
|
47
|
+
// Search term for filtering the menu list
|
|
48
|
+
const [menuSearch, setMenuSearch] = useState('');
|
|
49
|
+
|
|
46
50
|
const location = useLocation();
|
|
47
51
|
const query = new URLSearchParams(location.search);
|
|
48
52
|
let step = parseInt(query.get('step')) || 1;
|
|
@@ -97,40 +101,28 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
97
101
|
setSelectedMenus((prev) => (checked ? [...prev, id] : prev.filter((m) => m !== id)));
|
|
98
102
|
};
|
|
99
103
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// additional_queries.forEach(({ field, value }) => {
|
|
121
|
-
// payload[field] = value;
|
|
122
|
-
// });
|
|
123
|
-
// RolesAPI.createRole(payload)
|
|
124
|
-
// .then(() => {
|
|
125
|
-
// message.success('Role Added');
|
|
126
|
-
// setLoading(false);
|
|
127
|
-
// callback();
|
|
128
|
-
// })
|
|
129
|
-
// .catch(() => setLoading(false));
|
|
130
|
-
// }
|
|
131
|
-
// };
|
|
104
|
+
// Recursively filter menus by caption. A menu is kept if its caption matches
|
|
105
|
+
// the search term, or if any of its descendants match (ancestors are kept so
|
|
106
|
+
// the matching child stays reachable).
|
|
107
|
+
const filterMenus = (menus, term) => {
|
|
108
|
+
if (!term) return menus;
|
|
109
|
+
const lower = term.toLowerCase();
|
|
110
|
+
|
|
111
|
+
return menus.reduce((acc, menu) => {
|
|
112
|
+
const children = filterMenus(menu.sub_menus || [], term);
|
|
113
|
+
const selfMatch = (menu.caption || '').toLowerCase().includes(lower);
|
|
114
|
+
|
|
115
|
+
if (selfMatch || children.length > 0) {
|
|
116
|
+
acc.push({ ...menu, sub_menus: children });
|
|
117
|
+
}
|
|
118
|
+
return acc;
|
|
119
|
+
}, []);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const filteredMenuList = filterMenus(menuList, menuSearch);
|
|
123
|
+
|
|
132
124
|
const onSubmit = async (values) => {
|
|
133
|
-
|
|
125
|
+
onSubmittingChange?.(true);
|
|
134
126
|
|
|
135
127
|
// Find menus that were originally selected but now deselected
|
|
136
128
|
const deselectedMenus = originalMenus.filter((id) => !selectedMenus.includes(id));
|
|
@@ -153,13 +145,21 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
153
145
|
});
|
|
154
146
|
|
|
155
147
|
try {
|
|
156
|
-
await RolesAPI.createRole(payload); // single API
|
|
148
|
+
const res = await RolesAPI.createRole(payload); // single API
|
|
149
|
+
|
|
150
|
+
// it via success: false — show that message as a warning.
|
|
151
|
+
if (res?.success === false) {
|
|
152
|
+
message.warning(res.message || 'Failed to save role');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
157
156
|
message.success(formContent?.id ? 'Role Updated' : 'Role Added');
|
|
158
157
|
callback();
|
|
159
158
|
} catch (err) {
|
|
160
|
-
//
|
|
159
|
+
// Surface any unexpected backend error message as a warning.
|
|
160
|
+
message.warning(err?.message || err?.result?.message || 'Failed to save role');
|
|
161
161
|
} finally {
|
|
162
|
-
|
|
162
|
+
onSubmittingChange?.(false);
|
|
163
163
|
}
|
|
164
164
|
};
|
|
165
165
|
|
|
@@ -194,6 +194,7 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
194
194
|
display: 'flex',
|
|
195
195
|
justifyContent: 'space-between',
|
|
196
196
|
alignItems: 'center',
|
|
197
|
+
gap: 16,
|
|
197
198
|
marginBottom: 16,
|
|
198
199
|
}}
|
|
199
200
|
>
|
|
@@ -204,14 +205,20 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
204
205
|
<p style={{ color: '#999', marginBottom: 0 }}>Choose menus and set permissions</p>
|
|
205
206
|
</div>
|
|
206
207
|
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
<Input.Search
|
|
209
|
+
allowClear
|
|
210
|
+
placeholder="Search menus"
|
|
211
|
+
value={menuSearch}
|
|
212
|
+
onChange={(e) => setMenuSearch(e.target.value)}
|
|
213
|
+
style={{ maxWidth: 260 }}
|
|
214
|
+
/>
|
|
212
215
|
</div>
|
|
213
216
|
|
|
214
|
-
|
|
217
|
+
{filteredMenuList.length > 0 ? (
|
|
218
|
+
<MenuTree menus={filteredMenuList} selectedMenus={selectedMenus} toggleMenu={toggleMenu} searchActive={!!menuSearch} />
|
|
219
|
+
) : (
|
|
220
|
+
<p style={{ color: '#999', textAlign: 'center', padding: '16px 0' }}>No menus found</p>
|
|
221
|
+
)}
|
|
215
222
|
</div>
|
|
216
223
|
)}
|
|
217
224
|
</Form>
|
|
@@ -221,125 +228,3 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
|
|
|
221
228
|
};
|
|
222
229
|
|
|
223
230
|
export default RoleAdd;
|
|
224
|
-
|
|
225
|
-
// ------------------------
|
|
226
|
-
// Recursive Nested Menu Component
|
|
227
|
-
// ------------------------
|
|
228
|
-
// ------------------------
|
|
229
|
-
// Recursive Nested Menu Component
|
|
230
|
-
// ------------------------
|
|
231
|
-
const MenuTree = ({ menus, selectedMenus, toggleMenu, parentId = null }) => {
|
|
232
|
-
// Helper: check if parent should be checked
|
|
233
|
-
const isParentChecked = (menu) => {
|
|
234
|
-
if (!menu.sub_menus || menu.sub_menus.length === 0) {
|
|
235
|
-
return selectedMenus.includes(menu.id);
|
|
236
|
-
}
|
|
237
|
-
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
238
|
-
return allChildIds.every((id) => selectedMenus.includes(id));
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
// Helper: check if parent is indeterminate
|
|
242
|
-
const isParentIndeterminate = (menu) => {
|
|
243
|
-
if (!menu.sub_menus || menu.sub_menus.length === 0) return false;
|
|
244
|
-
const allChildIds = menu.sub_menus.map((c) => c.id);
|
|
245
|
-
const checkedCount = allChildIds.filter((id) => selectedMenus.includes(id)).length;
|
|
246
|
-
return checkedCount > 0 && checkedCount < allChildIds.length;
|
|
247
|
-
};
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<>
|
|
251
|
-
{menus.map((menu) => {
|
|
252
|
-
const children = menu.sub_menus || [];
|
|
253
|
-
const parentChecked = isParentChecked(menu);
|
|
254
|
-
const parentIndeterminate = isParentIndeterminate(menu);
|
|
255
|
-
|
|
256
|
-
const onParentChange = (checked) => {
|
|
257
|
-
toggleMenu(menu.id, checked);
|
|
258
|
-
// toggle children recursively
|
|
259
|
-
children.forEach((c) => toggleMenuRecursive(c, checked));
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
const toggleMenuRecursive = (menu, checked) => {
|
|
263
|
-
toggleMenu(menu.id, checked);
|
|
264
|
-
if (menu.sub_menus && menu.sub_menus.length > 0) {
|
|
265
|
-
menu.sub_menus.forEach((c) => toggleMenuRecursive(c, checked));
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
if (children.length === 0) {
|
|
270
|
-
return (
|
|
271
|
-
<div
|
|
272
|
-
style={{
|
|
273
|
-
justifyContent: 'space-between',
|
|
274
|
-
display: 'flex',
|
|
275
|
-
alignItems: 'center',
|
|
276
|
-
border: '1px solid rgba(198, 195, 195, 0.85)',
|
|
277
|
-
// borderRadius: 6,
|
|
278
|
-
padding: '12px 16px',
|
|
279
|
-
marginBottom: 6,
|
|
280
|
-
background: '#fff',
|
|
281
|
-
}}
|
|
282
|
-
>
|
|
283
|
-
<div
|
|
284
|
-
key={menu.id}
|
|
285
|
-
style={{
|
|
286
|
-
display: 'flex',
|
|
287
|
-
alignItems: 'center',
|
|
288
|
-
gap: 8,
|
|
289
|
-
}}
|
|
290
|
-
>
|
|
291
|
-
<Checkbox
|
|
292
|
-
checked={selectedMenus.includes(menu.id)}
|
|
293
|
-
onChange={(e) => {
|
|
294
|
-
const checked = e.target.checked;
|
|
295
|
-
|
|
296
|
-
toggleMenu(menu.id, checked);
|
|
297
|
-
|
|
298
|
-
// FORCE parent selection
|
|
299
|
-
if (checked && parentId) {
|
|
300
|
-
toggleMenu(parentId, true);
|
|
301
|
-
}
|
|
302
|
-
}}
|
|
303
|
-
/>
|
|
304
|
-
<span>{menu.caption}</span>
|
|
305
|
-
</div>
|
|
306
|
-
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
307
|
-
</div>
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return (
|
|
312
|
-
<Collapse
|
|
313
|
-
key={menu.id}
|
|
314
|
-
style={{ marginBottom: 6 }}
|
|
315
|
-
// defaultActiveKey={[menu.id]}
|
|
316
|
-
>
|
|
317
|
-
<Panel
|
|
318
|
-
key={menu.id}
|
|
319
|
-
header={
|
|
320
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
321
|
-
<div
|
|
322
|
-
style={{
|
|
323
|
-
display: 'flex',
|
|
324
|
-
alignItems: 'center',
|
|
325
|
-
gap: 8,
|
|
326
|
-
}}
|
|
327
|
-
onClick={(e) => e.stopPropagation()}
|
|
328
|
-
>
|
|
329
|
-
<Checkbox checked={parentChecked} indeterminate={parentIndeterminate} onChange={(e) => onParentChange(e.target.checked)} />
|
|
330
|
-
<span>{menu.caption}</span>
|
|
331
|
-
</div>
|
|
332
|
-
<Tag color={menu.is_visible === true ? 'green' : 'blue'}>{menu.is_visible === true ? 'VISIBLE' : 'HIDDEN'}</Tag>{' '}
|
|
333
|
-
</div>
|
|
334
|
-
}
|
|
335
|
-
>
|
|
336
|
-
<div style={{ paddingLeft: 20 }}>
|
|
337
|
-
<MenuTree menus={children} selectedMenus={selectedMenus} toggleMenu={toggleMenu} parentId={menu.id} />
|
|
338
|
-
</div>
|
|
339
|
-
</Panel>
|
|
340
|
-
</Collapse>
|
|
341
|
-
);
|
|
342
|
-
})}
|
|
343
|
-
</>
|
|
344
|
-
);
|
|
345
|
-
};
|
|
@@ -40,6 +40,12 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
40
40
|
|
|
41
41
|
const [single, setSingle] = useState({});
|
|
42
42
|
|
|
43
|
+
// tracks RoleAdd form submission so the Drawer footer button shows loading
|
|
44
|
+
const [submitting, setSubmitting] = useState(false);
|
|
45
|
+
|
|
46
|
+
// holds the RoleAdd form instance so the Drawer footer Save button can submit it
|
|
47
|
+
const formRef = useRef(null);
|
|
48
|
+
|
|
43
49
|
var [queryValue, setQueryValue] = useState('');
|
|
44
50
|
|
|
45
51
|
const { Search } = Input;
|
|
@@ -360,12 +366,26 @@ const RoleList = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
360
366
|
destroyOnClose
|
|
361
367
|
onClose={closeModal}
|
|
362
368
|
bodyStyle={{ paddingBottom: 80 }}
|
|
369
|
+
footer={
|
|
370
|
+
<div style={{ textAlign: 'right' }}>
|
|
371
|
+
<Space size="small">
|
|
372
|
+
<Button type="default" onClick={closeModal}>
|
|
373
|
+
Cancel
|
|
374
|
+
</Button>
|
|
375
|
+
<Button type="primary" loading={submitting} onClick={() => formRef.current?.submit()}>
|
|
376
|
+
Save
|
|
377
|
+
</Button>
|
|
378
|
+
</Space>
|
|
379
|
+
</div>
|
|
380
|
+
}
|
|
363
381
|
>
|
|
364
382
|
<model.ModalAddComponent
|
|
365
383
|
match={match}
|
|
366
384
|
model={model}
|
|
367
385
|
additional_queries={additional_queries}
|
|
368
386
|
formContent={single}
|
|
387
|
+
formRef={formRef}
|
|
388
|
+
onSubmittingChange={setSubmitting}
|
|
369
389
|
callback={(event) => {
|
|
370
390
|
closeModal();
|
|
371
391
|
getRecords();
|
|
@@ -163,6 +163,9 @@ class RolesAPI extends BaseAPI {
|
|
|
163
163
|
return ApiUtils.post({
|
|
164
164
|
url: `core-roles/save-core-role`,
|
|
165
165
|
formBody,
|
|
166
|
+
// Let the caller surface the backend message (as a warning) instead of
|
|
167
|
+
// the default red error toast from the http layer.
|
|
168
|
+
hideError: true,
|
|
166
169
|
});
|
|
167
170
|
};
|
|
168
171
|
|
|
@@ -160,7 +160,7 @@ export default function AssignRole() {
|
|
|
160
160
|
const role_id = role.id;
|
|
161
161
|
try {
|
|
162
162
|
const res = await MenusAPI.getCoreMenuByRoleId(role_id);
|
|
163
|
-
const allMenus = res.result
|
|
163
|
+
const allMenus = Array.isArray(res?.result) ? res.result : [];
|
|
164
164
|
|
|
165
165
|
setModules(allMenus);
|
|
166
166
|
} catch (e) {
|
|
@@ -264,11 +264,12 @@ export default function AssignRole() {
|
|
|
264
264
|
const handleViewAll = async () => {
|
|
265
265
|
setLoadingMenus(true);
|
|
266
266
|
setModules([]);
|
|
267
|
-
setActiveRole({ name: 'All Roles' });
|
|
267
|
+
setActiveRole({ name: 'All Roles', isViewAll: true });
|
|
268
268
|
|
|
269
269
|
try {
|
|
270
270
|
const res = await MenusAPI.getMenubyUser(id);
|
|
271
|
-
|
|
271
|
+
const userMenus = Array.isArray(res?.result?.menus) ? res.result.menus : [];
|
|
272
|
+
setModules(userMenus);
|
|
272
273
|
setLoadingMenus(false);
|
|
273
274
|
} catch (err) {
|
|
274
275
|
setLoadingMenus(false);
|
|
@@ -277,6 +278,18 @@ export default function AssignRole() {
|
|
|
277
278
|
}
|
|
278
279
|
};
|
|
279
280
|
|
|
281
|
+
/**
|
|
282
|
+
* Context-specific empty-state message for the right (menus) panel:
|
|
283
|
+
* - View Access for a user with no roles → no role assigned
|
|
284
|
+
* - A selected role with no menus → no menus assigned
|
|
285
|
+
* - Nothing selected yet → prompt to pick a role
|
|
286
|
+
*/
|
|
287
|
+
const emptyMenusMessage = !activeRole
|
|
288
|
+
? 'Select a role to view menus'
|
|
289
|
+
: activeRole.isViewAll
|
|
290
|
+
? 'No role has been assigned to this user.'
|
|
291
|
+
: 'No menus have been assigned to this role.';
|
|
292
|
+
|
|
280
293
|
return (
|
|
281
294
|
<section className="assign-role">
|
|
282
295
|
{/* LEFT PANEL */}
|
|
@@ -383,17 +396,19 @@ export default function AssignRole() {
|
|
|
383
396
|
padding: 16,
|
|
384
397
|
}}
|
|
385
398
|
>
|
|
386
|
-
|
|
387
|
-
<div className="
|
|
388
|
-
|
|
389
|
-
|
|
399
|
+
{modules.length > 0 && (
|
|
400
|
+
<div className="menus-header">
|
|
401
|
+
<div className="title">Menus {activeRole ? `– ${activeRole.name}` : ''}</div>
|
|
402
|
+
<div className="sub-text">You don’t have permission to edit this here. Update it in Role Settings.</div>
|
|
403
|
+
</div>
|
|
404
|
+
)}
|
|
390
405
|
|
|
391
406
|
<div className="menus-content">
|
|
392
407
|
{loadingMenus ? (
|
|
393
408
|
<Skeleton active paragraph={{ rows: 6 }} />
|
|
394
409
|
) : modules.length === 0 ? (
|
|
395
410
|
<div className="empty-state">
|
|
396
|
-
<Empty description=
|
|
411
|
+
<Empty description={emptyMenusMessage} />
|
|
397
412
|
</div>
|
|
398
413
|
) : (
|
|
399
414
|
<MenuTree menus={modules} selectedMenus={selectedMenus} toggleMenu={toggleMenu} showCheckbox={false} />
|