ui-soxo-bootstrap-core 2.6.40-dev.11 → 2.6.40-dev.13

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/PUBLISHING.md ADDED
@@ -0,0 +1,333 @@
1
+ # Publishing `ui-soxo-bootstrap-core` to npm
2
+
3
+ Complete guide for developers.
4
+
5
+ ---
6
+
7
+ ## Table of contents
8
+
9
+ 1. [Quick reference](#quick-reference)
10
+ 2. [Branch & version strategy](#branch--version-strategy)
11
+ 3. [`npm version` keys explained](#npm-version-keys-explained)
12
+ 4. [Publishing a dev pre-release (from `develop`)](#publishing-a-dev-pre-release-from-develop)
13
+ 5. [Publishing a stable release (from `master`)](#publishing-a-stable-release-from-master)
14
+ 6. [Verifying & installing](#verifying--installing)
15
+ 7. [Rules & dos / don'ts](#rules--dos--donts)
16
+ 8. [How auth works](#how-auth-works)
17
+ 9. [Troubleshooting](#troubleshooting)
18
+
19
+ ---
20
+
21
+ ## Quick reference
22
+
23
+ | Branch | Version format | npm dist-tag | Example | Who installs |
24
+ | --------- | ----------------- | ------------ | ---------------- | ------------------------------------- |
25
+ | `develop` | `X.Y.Z-dev.N` | `dev` | `2.6.40-dev.11` | Devs testing pre-releases |
26
+ | `master` | `X.Y.Z` | `latest` | `2.6.40` | Default install for everyone |
27
+
28
+ ---
29
+
30
+ ## Branch & version strategy
31
+
32
+ ```
33
+ feature branches ──► develop ──► master
34
+ (dev tag) (latest tag)
35
+ ```
36
+
37
+ - All day-to-day work merges into `develop`.
38
+ - Each meaningful commit on `develop` can be published as a `-dev.N` pre-release for QA / integration testing.
39
+ - When the code is ready for production, **merge `develop` → `master`**, then bump and tag a stable version on `master`.
40
+ - `master` is the source of `latest` on npm. End users get this by default.
41
+
42
+ ---
43
+
44
+ ## `npm version` keys explained
45
+
46
+ `npm version <key>` bumps `package.json`, creates a commit, **and** creates a git tag in one shot. Use it instead of editing `package.json` by hand.
47
+
48
+ ### Stable bumps (use on `master`)
49
+
50
+ | Key | From | To | When to use |
51
+ | --------- | ------- | -------- | ------------------------------------------------- |
52
+ | `patch` | 2.6.40 | 2.6.41 | Bug fixes only, no API change |
53
+ | `minor` | 2.6.40 | 2.7.0 | New features, backwards-compatible |
54
+ | `major` | 2.6.40 | 3.0.0 | Breaking changes (consumers must update code) |
55
+
56
+ ```bash
57
+ npm version patch # → 2.6.41
58
+ npm version minor # → 2.7.0
59
+ npm version major # → 3.0.0
60
+ ```
61
+
62
+ ### Pre-release bumps (use on `develop`)
63
+
64
+ | Key | From | To | When to use |
65
+ | ---------------------------------- | --------------- | ------------------- | ------------------------------------------- |
66
+ | `prerelease --preid=dev` | 2.6.40-dev.10 | 2.6.40-dev.11 | Another iteration of the current pre-release |
67
+ | `prepatch --preid=dev` | 2.6.40 | 2.6.41-dev.0 | Start pre-releases for the next patch |
68
+ | `preminor --preid=dev` | 2.6.40 | 2.7.0-dev.0 | Start pre-releases for the next minor |
69
+ | `premajor --preid=dev` | 2.6.40 | 3.0.0-dev.0 | Start pre-releases for the next major |
70
+
71
+ ```bash
72
+ npm version prerelease --preid=dev # → 2.6.40-dev.11 (most common on develop)
73
+ npm version preminor --preid=dev # → 2.7.0-dev.0 (kick off next minor cycle)
74
+ ```
75
+
76
+ ### Explicit version
77
+
78
+ If you know the exact string you want:
79
+
80
+ ```bash
81
+ npm version 2.6.40-dev.12
82
+ npm version 2.7.0
83
+ ```
84
+
85
+ ### Useful flags
86
+
87
+ | Flag | Effect |
88
+ | --------------------- | -------------------------------------------------------------- |
89
+ | `--no-git-tag-version` | Bump `package.json` only — no commit, no tag (rarely needed) |
90
+ | `-m "Release %s"` | Custom commit message; `%s` is replaced with the new version |
91
+
92
+ ---
93
+
94
+ ## Publishing a dev pre-release (from `develop`)
95
+
96
+ Use this for every internal QA / integration build.
97
+
98
+ ### Step 1 — Make sure `develop` is up to date
99
+
100
+ ```bash
101
+ git checkout develop
102
+ git pull
103
+ ```
104
+
105
+ ### Step 2 — Bump the version
106
+
107
+ Pick **one**:
108
+
109
+ ```bash
110
+ # Common case: next dev iteration of current version
111
+ npm version prerelease --preid=dev
112
+ # Example: 2.6.40-dev.10 → 2.6.40-dev.11
113
+ ```
114
+
115
+ ```bash
116
+ # Starting a new patch/minor/major cycle
117
+ npm version preminor --preid=dev
118
+ # Example: 2.6.40 → 2.7.0-dev.0
119
+ ```
120
+
121
+ ```bash
122
+ # Or explicit
123
+ npm version 2.6.40-dev.12
124
+ ```
125
+
126
+ `npm version` automatically:
127
+ - Updates `package.json`
128
+ - Creates a commit `2.6.40-dev.11`
129
+ - Creates a git tag `v2.6.40-dev.11`
130
+
131
+ ### Step 3 — Push commit & tag
132
+
133
+ ```bash
134
+ git push origin develop --follow-tags
135
+ ```
136
+
137
+ ### Step 4 — Create a GitHub Release
138
+
139
+ 1. Go to https://github.com/soxo-tech/bootstrap-core/releases
140
+ 2. Click **Draft a new release**
141
+ 3. **Choose a tag** → pick the tag you just pushed (e.g. `v2.6.40-dev.11`)
142
+ 4. Title: same as the tag (`v2.6.40-dev.11`)
143
+ 5. Description: short summary of what's in this build (optional but recommended)
144
+ 6. ✅ Tick **Set as a pre-release** (so it's flagged as not stable)
145
+ 7. Click **Publish release**
146
+
147
+ GitHub Actions will run the `npm-publish.yml` workflow and publish to npm with `dist-tag: dev`.
148
+
149
+ ### Step 5 — Verify
150
+
151
+ Wait ~1–2 minutes, then:
152
+
153
+ ```bash
154
+ npm view ui-soxo-bootstrap-core dist-tags
155
+ # Expect: { latest: '<some stable>', dev: '2.6.40-dev.11' }
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Publishing a stable release (from `master`)
161
+
162
+ Use this when a `develop` snapshot has been QA'd and is ready for production.
163
+
164
+ ### Step 1 — Merge `develop` into `master`
165
+
166
+ ```bash
167
+ git checkout master
168
+ git pull
169
+ git merge develop
170
+ git push
171
+ ```
172
+
173
+ Resolve any conflicts as you normally would. Don't squash unless you have a reason to.
174
+
175
+ ### Step 2 — Bump the version on `master`
176
+
177
+ Pick the bump that matches the change scope (relative to the last **stable** version on npm):
178
+
179
+ ```bash
180
+ npm version patch # 2.6.40 → 2.6.41 (bug fixes)
181
+ npm version minor # 2.6.40 → 2.7.0 (new features)
182
+ npm version major # 2.6.40 → 3.0.0 (breaking changes)
183
+ ```
184
+
185
+ This creates a commit and a tag like `v2.6.41` (no `-dev` suffix).
186
+
187
+ ### Step 3 — Push commit & tag
188
+
189
+ ```bash
190
+ git push origin master --follow-tags
191
+ ```
192
+
193
+ ### Step 4 — Create a GitHub Release
194
+
195
+ 1. Go to https://github.com/soxo-tech/bootstrap-core/releases
196
+ 2. **Draft a new release**
197
+ 3. **Choose a tag** → pick the stable tag (e.g. `v2.6.41`)
198
+ 4. Title: `v2.6.41`
199
+ 5. Description: changelog — what's new, what's fixed, any breaking changes
200
+ 6. ❌ Do **not** tick "Set as a pre-release"
201
+ 7. Click **Publish release**
202
+
203
+ GitHub Actions publishes to npm with `dist-tag: latest`.
204
+
205
+ ### Step 5 — Verify
206
+
207
+ ```bash
208
+ npm view ui-soxo-bootstrap-core dist-tags
209
+ # Expect: { latest: '2.6.41', dev: '...' }
210
+ ```
211
+
212
+ ### Step 6 (optional but recommended) — Sync `develop`
213
+
214
+ After a stable release, fast-forward `develop` to include the new version commit so the next dev cycle starts from the right place:
215
+
216
+ ```bash
217
+ git checkout develop
218
+ git merge master
219
+ git push
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Verifying & installing
225
+
226
+ ### Check what's published
227
+
228
+ ```bash
229
+ npm view ui-soxo-bootstrap-core dist-tags
230
+ npm view ui-soxo-bootstrap-core versions --json
231
+ ```
232
+
233
+ ### Install in another project
234
+
235
+ ```bash
236
+ # Latest stable (from master)
237
+ npm install ui-soxo-bootstrap-core
238
+
239
+ # Latest dev pre-release (from develop)
240
+ npm install ui-soxo-bootstrap-core@dev
241
+
242
+ # Specific version
243
+ npm install ui-soxo-bootstrap-core@2.6.40-dev.11
244
+ ```
245
+
246
+ ---
247
+
248
+ ## Rules & dos / don'ts
249
+
250
+ | ✅ Do | ❌ Don't |
251
+ | ----------------------------------------------------- | ------------------------------------------------ |
252
+ | Use `npm version` to bump (it tags atomically) | Edit `package.json` version by hand |
253
+ | Tag matches `package.json` exactly | Publish manually from your laptop |
254
+ | `develop` → only `-dev.N` versions | Tag a stable `2.6.40` from `develop` |
255
+ | `master` → only stable versions | Tag a `-dev` version from `master` |
256
+ | Push with `--follow-tags` | Forget to push the tag (Release won't find it) |
257
+ | Create a GitHub Release to trigger publish | Reuse / move existing tags |
258
+
259
+ ---
260
+
261
+ ## How auth works
262
+
263
+ Publishing uses **OIDC trusted publishing** — no `NPM_TOKEN` secret to rotate.
264
+
265
+ When the workflow runs:
266
+ 1. GitHub Actions issues a short-lived OIDC token identifying the workflow + repo + ref.
267
+ 2. The npm CLI exchanges that token for a one-time publish credential.
268
+ 3. npm validates against the Trusted Publisher config on npmjs.com (`soxo-tech/bootstrap-core` + `npm-publish.yml`).
269
+ 4. Publish succeeds.
270
+
271
+ If you ever need to inspect or change auth:
272
+ - https://www.npmjs.com/package/ui-soxo-bootstrap-core/access — Trusted Publisher config
273
+ - [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml) — the workflow
274
+
275
+ ---
276
+
277
+ ## Troubleshooting
278
+
279
+ ### "Release tag does not match `package.json` version"
280
+
281
+ The workflow checks that the git tag is exactly `v<package.json version>`. If they don't match, the workflow fails fast.
282
+
283
+ **Fix:** Delete the Release & tag, run `npm version <correct>` to bump cleanly, push, and re-create the Release.
284
+
285
+ ### Workflow runs but `npm publish` fails with 404
286
+
287
+ Likely auth issue. Check:
288
+ 1. **Trusted Publisher exists** on https://www.npmjs.com/package/ui-soxo-bootstrap-core/access
289
+ 2. **Repository** field is `bootstrap-core` (not `soxo-bootstrap-core`)
290
+ 3. **Workflow filename** is `npm-publish.yml` (just the filename, not the path)
291
+ 4. **Organization or user** is `soxo-tech`
292
+
293
+ ### Workflow fails with 422 "Unsupported source repository visibility: private"
294
+
295
+ Someone added `--provenance` to the publish step. Provenance requires a public repo. Remove `--provenance` from the `npm publish` line in [.github/workflows/npm-publish.yml](.github/workflows/npm-publish.yml).
296
+
297
+ ### Version already exists on npm
298
+
299
+ You can't republish the same version. Bump again and create a new Release.
300
+
301
+ ### Tag pushed but no Release auto-publishes
302
+
303
+ The workflow trigger is `on: release: [created]` — pushing a tag alone doesn't publish. You must **create a Release on GitHub** for the workflow to fire.
304
+
305
+ ---
306
+
307
+ ## Diagram of the full flow
308
+
309
+ ```
310
+ ┌─────────────────┐
311
+ │ feature/* │
312
+ └────────┬─────────┘
313
+ │ merge
314
+
315
+ ┌─────────────────┐
316
+ │ develop │
317
+ │ │ ── npm version prerelease --preid=dev
318
+ │ │ ── git push --follow-tags
319
+ │ │ ── GitHub Release (pre-release)
320
+ │ │ ↓
321
+ │ │ workflow publishes → npm @dev
322
+ └────────┬─────────┘
323
+ │ merge (when stable)
324
+
325
+ ┌─────────────────┐
326
+ │ master │
327
+ │ │ ── npm version patch | minor | major
328
+ │ │ ── git push --follow-tags
329
+ │ │ ── GitHub Release (stable)
330
+ │ │ ↓
331
+ │ │ workflow publishes → npm @latest
332
+ └─────────────────┘
333
+ ```
@@ -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>
@@ -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));
244
+
245
+ const searchActive = !!query;
246
+ const visibleItems = filterMenus(records, query);
207
247
 
208
- const visibleItems = filtered;
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,7 +351,7 @@ 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
357
  <Button onClick={getRecords} size={'small'} type="default">
@@ -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}
@@ -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,11 @@ function NestedMenu({
536
597
  }
537
598
 
538
599
  return (
539
- <Collapse accordion>
600
+ <Collapse
601
+ accordion={!searchActive}
602
+ activeKey={openKeys}
603
+ onChange={(keys) => setOpenKeys(Array.isArray(keys) ? keys : keys ? [keys] : [])}
604
+ >
540
605
  {localItems.map((child, index) => (
541
606
  <Panel
542
607
  key={child.id}
@@ -563,6 +628,8 @@ function NestedMenu({
563
628
  step={step + 1}
564
629
  items={child.sub_menus || []}
565
630
  dragMode={dragMode}
631
+ searchActive={searchActive}
632
+ query={query}
566
633
  setSelectedRecord={setSelectedRecord}
567
634
  setDrawerTitle={setDrawerTitle}
568
635
  setDrawerVisible={setDrawerVisible}
@@ -145,11 +145,19 @@ const RoleAdd = ({ model, callback, edit, formContent = {}, match, additional_qu
145
145
  });
146
146
 
147
147
  try {
148
- 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
+
149
156
  message.success(formContent?.id ? 'Role Updated' : 'Role Added');
150
157
  callback();
151
158
  } catch (err) {
152
- // 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');
153
161
  } finally {
154
162
  onSubmittingChange?.(false);
155
163
  }
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ui-soxo-bootstrap-core",
3
- "version": "2.6.40-dev.11",
3
+ "version": "2.6.40-dev.13",
4
4
  "description": "All the Core Components for you to start",
5
5
  "keywords": [
6
6
  "all in one"