ui-soxo-bootstrap-core 2.6.40 → 2.6.42

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 (
@@ -474,7 +526,7 @@ function panelActions(item, model, setSelectedRecord, setDrawerTitle, setDrawerV
474
526
  <EditOutlined />
475
527
  </Button>
476
528
  )}
477
- <Button
529
+ {/* <Button
478
530
  size="small"
479
531
  type="default"
480
532
  onClick={() => {
@@ -484,7 +536,7 @@ function panelActions(item, model, setSelectedRecord, setDrawerTitle, setDrawerV
484
536
  }}
485
537
  >
486
538
  <CopyOutlined />
487
- </Button>
539
+ </Button> */}
488
540
  <Popconfirm title="Are you sure?" onConfirm={() => deleteRecord(item)}>
489
541
  <Button danger size="small" type="default">
490
542
  <DeleteOutlined />
@@ -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;