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.
@@ -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
- {Menus.screenMenus(modules, 'order')
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
- export default function DraggableWrapper({ id, index, movePanel, item, dragEnabled, level, parentId, onCrossLevelMove, canAcceptChildren }) {
5
- const autoScrollWindow = (monitor) => {
6
- const offset = monitor.getClientOffset();
7
- if (!offset) return;
8
-
9
- const EDGE = 80;
10
- const SPEED = 20;
11
-
12
- const viewportHeight = window.innerHeight;
13
-
14
- // 🔼 scroll UP
15
- if (offset.y < EDGE) {
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
- // 🔽 scroll DOWN
20
- if (offset.y > viewportHeight - EDGE) {
21
- window.scrollBy(0, SPEED);
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 name="path" label="Path" rules={[{ required: true, message: 'Path is required' }]}>
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 name="route" label="Route" rules={[{ required: true, message: 'Route is required' }]}>
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.name}" to level ${targetLevel}`);
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
- const filtered = records.filter((r) => r.caption?.toUpperCase().includes(query.toUpperCase()));
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 visibleItems = filtered;
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="Enter Search Value" allowClear style={{ width: 300, marginBottom: '0px' }} onChange={onSearch} />
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 accordion>
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, Collapse, Checkbox, Tag } from 'antd';
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
- // Submit handler
101
- // const onSubmit = (values) => {
102
- // setLoading(true);
103
-
104
- // const payload = {
105
- // ...values,
106
- // menu_ids
107
- // : selectedMenus,
108
- // attributes: JSON.stringify(values.attributes || {}),
109
- // };
110
-
111
- // if (formContent.id) {
112
- // RolesAPI.updateRole({ id: formContent.id, formBody: payload })
113
- // .then(() => {
114
- // message.success('Role Updated');
115
- // setLoading(false);
116
- // callback();
117
- // })
118
- // .catch(() => setLoading(false));
119
- // } else {
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
- setLoading(true);
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
- // message.error('Something went wrong');
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
- setLoading(false);
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
- <Form.Item style={{ marginBottom: 0 }}>
208
- <Button loading={loading} htmlType="submit" type="primary">
209
- Save
210
- </Button>
211
- </Form.Item>
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
- <MenuTree menus={menuList} selectedMenus={selectedMenus} toggleMenu={toggleMenu} />
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
- setModules(res?.result?.menus ?? []);
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
- <div className="menus-header">
387
- <div className="title">Menus {activeRole ? `– ${activeRole.name}` : ''}</div>
388
- <div className="sub-text">You don’t have permission to edit this here. Update it in Role Settings.</div>
389
- </div>
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="Select a role to view menus" />
411
+ <Empty description={emptyMenusMessage} />
397
412
  </div>
398
413
  ) : (
399
414
  <MenuTree menus={modules} selectedMenus={selectedMenus} toggleMenu={toggleMenu} showCheckbox={false} />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.40",
3
+ "version": "2.6.41",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"