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 +333 -0
- package/core/lib/components/sidemenu/sidemenu.js +66 -147
- package/core/lib/models/menus/components/menu-add/menu-add.js +2 -2
- package/core/models/menus/components/menu-add/menu-add.js +31 -4
- package/core/models/menus/components/menu-lists/menu-lists.js +76 -9
- package/core/models/roles/components/role-add/role-add.js +10 -2
- package/core/models/roles/roles.js +3 -0
- package/package.json +1 -1
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
|
-
{
|
|
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
|
|
239
|
+
<Form.Item
|
|
240
|
+
name="path"
|
|
241
|
+
label="Path"
|
|
242
|
+
rules={[
|
|
243
|
+
{ required: true, message: 'Path is required' },
|
|
244
|
+
{
|
|
245
|
+
pattern: /^\//,
|
|
246
|
+
message: 'Path must start with /',
|
|
247
|
+
},
|
|
248
|
+
]}
|
|
249
|
+
>
|
|
233
250
|
<Input placeholder="Enter path" />
|
|
234
251
|
</Form.Item>
|
|
235
252
|
{/* Path Ends */}
|
|
236
253
|
|
|
237
254
|
{/* Route */}
|
|
238
|
-
<Form.Item
|
|
255
|
+
<Form.Item
|
|
256
|
+
name="route"
|
|
257
|
+
label="Route"
|
|
258
|
+
rules={[
|
|
259
|
+
{ required: false, message: 'Route is required' },
|
|
260
|
+
{
|
|
261
|
+
pattern: /^\//,
|
|
262
|
+
message: 'Route must start with /',
|
|
263
|
+
},
|
|
264
|
+
]}
|
|
265
|
+
>
|
|
239
266
|
<Input placeholder="Enter route" />
|
|
240
267
|
</Form.Item>
|
|
241
268
|
{/* Route Ends */}
|
|
@@ -30,6 +30,9 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
30
30
|
const [query, setQuery] = useState('');
|
|
31
31
|
const [dragMode, setDragMode] = useState(false);
|
|
32
32
|
const [orderChanged, setOrderChanged] = useState(false);
|
|
33
|
+
// keys of currently-expanded top-level panels. Seeded from the search results
|
|
34
|
+
// when the query changes, but the user can freely toggle panels afterwards.
|
|
35
|
+
const [openKeys, setOpenKeys] = useState([]);
|
|
33
36
|
|
|
34
37
|
const [nextId, setNextId] = useState(10000);
|
|
35
38
|
|
|
@@ -181,6 +184,16 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
181
184
|
return;
|
|
182
185
|
}
|
|
183
186
|
|
|
187
|
+
// Prevent moving a menu into one of its own descendants
|
|
188
|
+
// (e.g. A > B, C — A cannot be dropped under B or C).
|
|
189
|
+
if (targetParentId) {
|
|
190
|
+
const draggedNode = findMenuById(records, draggedItem.id);
|
|
191
|
+
if (draggedNode && findMenuById(draggedNode.sub_menus || [], targetParentId)) {
|
|
192
|
+
message.warning('Cannot move a menu into its own sub menu');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
184
197
|
// Remove item from original location
|
|
185
198
|
const { newItems: itemsAfterRemove, foundItem } = findAndRemoveItem(records, draggedItem.id);
|
|
186
199
|
|
|
@@ -194,7 +207,7 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
194
207
|
|
|
195
208
|
setRecords(finalItems);
|
|
196
209
|
setOrderChanged(true);
|
|
197
|
-
message.success(`Moved "${foundItem.
|
|
210
|
+
message.success(`Moved "${foundItem.caption}" to level ${targetLevel}`);
|
|
198
211
|
},
|
|
199
212
|
[records]
|
|
200
213
|
);
|
|
@@ -203,9 +216,42 @@ const MenuLists = ({ model, match, relativeAdd = false, additional_queries = [],
|
|
|
203
216
|
model.delete(rec).then(loadMenus);
|
|
204
217
|
};
|
|
205
218
|
|
|
206
|
-
|
|
219
|
+
// Recursively filter the menu tree by caption. A menu is kept if its own
|
|
220
|
+
// caption matches OR any descendant matches. When the menu itself matches we
|
|
221
|
+
// keep its full subtree; when only a descendant matches we keep just the
|
|
222
|
+
// pruned path so the matching item stays reachable.
|
|
223
|
+
const filterMenus = (menus, q) => {
|
|
224
|
+
if (!q) return menus || [];
|
|
225
|
+
const lower = q.toLowerCase();
|
|
226
|
+
|
|
227
|
+
return (menus || []).reduce((acc, menu) => {
|
|
228
|
+
const selfMatch = (menu.caption || '').toLowerCase().includes(lower);
|
|
229
|
+
if (selfMatch) {
|
|
230
|
+
acc.push({ ...menu });
|
|
231
|
+
return acc;
|
|
232
|
+
}
|
|
233
|
+
const subFiltered = filterMenus(menu.sub_menus || [], q);
|
|
234
|
+
if (subFiltered.length) {
|
|
235
|
+
acc.push({ ...menu, sub_menus: subFiltered });
|
|
236
|
+
}
|
|
237
|
+
return acc;
|
|
238
|
+
}, []);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
// Ids of items (at one level) that have sub_menus — used to auto-expand
|
|
242
|
+
// every branch leading to a search match.
|
|
243
|
+
const expandableKeys = (items) => (items || []).filter((m) => m.sub_menus && m.sub_menus.length > 0).map((m) => String(m.id));
|
|
244
|
+
|
|
245
|
+
const searchActive = !!query;
|
|
246
|
+
const visibleItems = filterMenus(records, query);
|
|
207
247
|
|
|
208
|
-
|
|
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="
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|