domma-cms 1.6.60 → 1.7.0
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/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,1112 @@
|
|
|
1
|
+
# Admin Sidebar via Menus Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Replace the hand-rolled `admin/js/config/sidebar-config.js` flat-array sidebar with a menu rendered from the Menus system at a new built-in slot `admin-sidebar`. Plugins extend via the menu-locations registry. The Menus admin can edit the sidebar visually. Native `<details>`/`<summary>` provides expand/collapse for nested folders.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A new core slot `admin-sidebar` is registered in the Menus service. A one-shot migration writes the current sidebar structure to `config/menus/admin-sidebar.json` on first boot and maps the slot. A new `admin/js/lib/sidebar-renderer.js` fetches the menu, merges plugin-registered items, synthesises per-project sub-trees, filters by user permission, and renders nested `<details>` elements. The old `Domma.elements.sidebar(...)` call site in `app.js` is replaced; `sidebar-config.js` becomes a deprecated stub.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Fastify 5 (ESM), Domma SPA admin (`$`/`E`/`F`/`T`/`I`/`H`/`S`), `node:test`, native `<details>`/`<summary>` for expand/collapse, no upstream `domma-js` dependency.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md`
|
|
12
|
+
|
|
13
|
+
**Critical project rules** (from `CLAUDE.md` + memory):
|
|
14
|
+
|
|
15
|
+
- Server always running on port **4096** - never start/stop.
|
|
16
|
+
- Domma admin uses `$`, `H.get/post/put/delete`, `E.toast/confirm`, `S.set/get`, `Domma.icons.scan`.
|
|
17
|
+
- `.get(0)` to unwrap collections.
|
|
18
|
+
- T.create render fns are `(value, row)` two-arg - NOT single-arg.
|
|
19
|
+
- Admin uses `Cache-Control: no-store` - no `?v=N` cache-busting query strings.
|
|
20
|
+
- UI changes must be browser-tested (memory: `feedback_no_verified_without_browser.md`).
|
|
21
|
+
- Do NOT commit during implementation - user commits separately (memory: `feedback_no_commits.md`).
|
|
22
|
+
- No `Co-Authored-By` footers.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## File Map
|
|
27
|
+
|
|
28
|
+
| Path | Purpose | Action |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `server/services/menus.js` | Register `admin-sidebar` core slot; allow `permission` field on items | Modify |
|
|
31
|
+
| `server/services/sidebar-migration.js` | One-shot seed of `admin-sidebar.json` + locations patch | Create |
|
|
32
|
+
| `server/services/hooks.js` | Add `registerSidebarItem()` export | Modify |
|
|
33
|
+
| `server/services/plugins.js` | Expose `registerSidebarItem` to plugins (hooks object) | Modify |
|
|
34
|
+
| `server/server.js` | Wire `sidebar-migration` into boot; register `/api/sidebar/registered-items` route | Modify |
|
|
35
|
+
| `server/routes/api/sidebar.js` | New route module for `/api/sidebar/registered-items` | Create |
|
|
36
|
+
| `admin/js/lib/sidebar-renderer.js` | The new sidebar renderer | Create |
|
|
37
|
+
| `admin/js/app.js` | Replace `Domma.elements.sidebar(...)` call with new renderer | Modify |
|
|
38
|
+
| `admin/js/config/sidebar-config.js` | Stub: returns `[]` + deprecation comment | Modify (gut) |
|
|
39
|
+
| `admin/js/views/menu-editor.js` | Add `permission` field to item-row template + save handler | Modify |
|
|
40
|
+
| `admin/css/admin.css` | Styles for `<details>`-based sidebar | Modify |
|
|
41
|
+
| `tests/services/menus.test.js` | Add tests for `admin-sidebar` slot registration + `permission` field acceptance | Modify |
|
|
42
|
+
| `tests/services/sidebar-migration.test.js` | New - migration tests against temp config dirs | Create |
|
|
43
|
+
| `tests/routes/sidebar.test.js` | New - smoke tests for `/api/sidebar/registered-items` | Create |
|
|
44
|
+
| `docs/configuration.md` | Document `admin-sidebar` slot + `permission` field | Modify |
|
|
45
|
+
| `docs/plugin-development.md` | Document `hooks.registerSidebarItem()` | Modify |
|
|
46
|
+
| `docs/api-reference.md` | Document `/api/sidebar/registered-items` | Modify |
|
|
47
|
+
| `CLAUDE.md` | Note menu-data-driven admin sidebar | Modify |
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Task 1: Register `admin-sidebar` core slot + accept `permission` on items
|
|
52
|
+
|
|
53
|
+
**Files:**
|
|
54
|
+
|
|
55
|
+
- Modify: `server/services/menus.js`
|
|
56
|
+
- Modify: `tests/services/menus.test.js`
|
|
57
|
+
|
|
58
|
+
### Step 1.1: Write the test
|
|
59
|
+
|
|
60
|
+
Append to `tests/services/menus.test.js`:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
test('registry - admin-sidebar core slot is registered at module load', () => {
|
|
64
|
+
const slots = getLocationRegistry().map(s => s.slot);
|
|
65
|
+
assert.ok(slots.includes('admin-sidebar'), 'admin-sidebar slot should be registered by core');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('validateMenu - accepts `permission` field on items without complaint', () => {
|
|
69
|
+
const errors = validateMenu({
|
|
70
|
+
slug: 'admin-sidebar-test',
|
|
71
|
+
name: 'X',
|
|
72
|
+
items: [
|
|
73
|
+
{text: 'Pages', url: '#/pages', permission: 'pages'},
|
|
74
|
+
{text: 'No permission item', url: '#/foo'}
|
|
75
|
+
]
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(errors, []);
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Step 1.2: Run to confirm failure
|
|
82
|
+
|
|
83
|
+
Run: `node --test tests/services/menus.test.js 2>&1 | tail -10`
|
|
84
|
+
|
|
85
|
+
Expected: the `admin-sidebar` slot test fails (slot not yet registered). The `permission` test may already pass because `validateMenu` doesn't currently reject unknown item keys.
|
|
86
|
+
|
|
87
|
+
### Step 1.3: Register the core slot
|
|
88
|
+
|
|
89
|
+
In `server/services/menus.js`, find the existing core-slot registration block (`registerLocation('navbar', …)` etc., around the bottom of the module). Add:
|
|
90
|
+
|
|
91
|
+
```js
|
|
92
|
+
registerLocation('admin-sidebar', {
|
|
93
|
+
label: 'Admin sidebar',
|
|
94
|
+
description: 'The left-side navigation in the admin panel',
|
|
95
|
+
source: 'core',
|
|
96
|
+
maxDepth: null
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Step 1.4: Confirm `permission` is preserved through createMenu/updateMenu
|
|
101
|
+
|
|
102
|
+
In `server/services/menus.js`, locate `createMenu`. The function builds the persisted menu shape from `input`. Items are written via `items: Array.isArray(input.items) ? input.items : []` - the whole item object passes through, so `permission` is preserved without code change. Verify by reading the code.
|
|
103
|
+
|
|
104
|
+
If the validateMenu test from Step 1.1 already passes (because validateMenu doesn't whitelist item keys), no further change is needed.
|
|
105
|
+
|
|
106
|
+
### Step 1.5: Run tests
|
|
107
|
+
|
|
108
|
+
Run: `node --test tests/services/menus.test.js 2>&1 | tail -5`
|
|
109
|
+
|
|
110
|
+
Expected: all tests pass (including the two new ones).
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Task 2: Sidebar migration service
|
|
115
|
+
|
|
116
|
+
**Files:**
|
|
117
|
+
|
|
118
|
+
- Create: `server/services/sidebar-migration.js`
|
|
119
|
+
- Create: `tests/services/sidebar-migration.test.js`
|
|
120
|
+
|
|
121
|
+
### Step 2.1: Write the migration tests
|
|
122
|
+
|
|
123
|
+
Create `tests/services/sidebar-migration.test.js`:
|
|
124
|
+
|
|
125
|
+
```js
|
|
126
|
+
/**
|
|
127
|
+
* Sidebar migration - exercises the one-shot seed flow on a temporary
|
|
128
|
+
* config dir so the real project config is untouched.
|
|
129
|
+
*/
|
|
130
|
+
import {test} from 'node:test';
|
|
131
|
+
import assert from 'node:assert/strict';
|
|
132
|
+
import fs from 'fs/promises';
|
|
133
|
+
import path from 'path';
|
|
134
|
+
import os from 'os';
|
|
135
|
+
import {runMigration} from '../../server/services/sidebar-migration.js';
|
|
136
|
+
|
|
137
|
+
async function scratch() {
|
|
138
|
+
return fs.mkdtemp(path.join(os.tmpdir(), 'sidebar-migration-'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test('no-op when admin-sidebar.json already exists', async () => {
|
|
142
|
+
const dir = await scratch();
|
|
143
|
+
await fs.mkdir(path.join(dir, 'menus'), {recursive: true});
|
|
144
|
+
await fs.writeFile(path.join(dir, 'menus', 'admin-sidebar.json'), '{}', 'utf8');
|
|
145
|
+
const result = await runMigration({configDir: dir});
|
|
146
|
+
assert.equal(result.migrated, false);
|
|
147
|
+
assert.equal(result.reason, 'admin-sidebar.json already present');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('seeds admin-sidebar.json with the standard tree', async () => {
|
|
151
|
+
const dir = await scratch();
|
|
152
|
+
const result = await runMigration({configDir: dir});
|
|
153
|
+
assert.equal(result.migrated, true);
|
|
154
|
+
|
|
155
|
+
const menu = JSON.parse(await fs.readFile(path.join(dir, 'menus', 'admin-sidebar.json'), 'utf8'));
|
|
156
|
+
assert.equal(menu.slug, 'admin-sidebar');
|
|
157
|
+
assert.ok(Array.isArray(menu.items));
|
|
158
|
+
assert.ok(menu.items.length >= 5, 'should seed at least 5 top-level sections');
|
|
159
|
+
const flat = [];
|
|
160
|
+
const walk = (xs) => xs.forEach(x => { flat.push(x); if (x.items) walk(x.items); });
|
|
161
|
+
walk(menu.items);
|
|
162
|
+
const pages = flat.find(x => x.text === 'Pages');
|
|
163
|
+
assert.ok(pages, 'Pages item should exist in seeded tree');
|
|
164
|
+
assert.equal(pages.permission, 'pages');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('patches menu-locations.json to add admin-sidebar mapping', async () => {
|
|
168
|
+
const dir = await scratch();
|
|
169
|
+
await fs.writeFile(path.join(dir, 'menu-locations.json'), '{"navbar":"main"}', 'utf8');
|
|
170
|
+
await runMigration({configDir: dir});
|
|
171
|
+
const map = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
|
|
172
|
+
assert.equal(map.navbar, 'main');
|
|
173
|
+
assert.equal(map['admin-sidebar'], 'admin-sidebar');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('creates menu-locations.json when absent', async () => {
|
|
177
|
+
const dir = await scratch();
|
|
178
|
+
await runMigration({configDir: dir});
|
|
179
|
+
const map = JSON.parse(await fs.readFile(path.join(dir, 'menu-locations.json'), 'utf8'));
|
|
180
|
+
assert.equal(map['admin-sidebar'], 'admin-sidebar');
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Step 2.2: Run to confirm failure
|
|
185
|
+
|
|
186
|
+
Run: `node --test tests/services/sidebar-migration.test.js`
|
|
187
|
+
|
|
188
|
+
Expected: `ERR_MODULE_NOT_FOUND` for `sidebar-migration.js`.
|
|
189
|
+
|
|
190
|
+
### Step 2.3: Implement the migration
|
|
191
|
+
|
|
192
|
+
Create `server/services/sidebar-migration.js`:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
/**
|
|
196
|
+
* Admin sidebar auto-migration.
|
|
197
|
+
*
|
|
198
|
+
* Runs once on server boot. Idempotent - guarded by the existence of
|
|
199
|
+
* config/menus/admin-sidebar.json. Seeds the admin sidebar menu with the
|
|
200
|
+
* standard tree (Overview, Projects, Content, Data, System, Documentation)
|
|
201
|
+
* and patches config/menu-locations.json to map the `admin-sidebar` slot.
|
|
202
|
+
*/
|
|
203
|
+
import fs from 'fs/promises';
|
|
204
|
+
import path from 'path';
|
|
205
|
+
import {fileURLToPath} from 'url';
|
|
206
|
+
|
|
207
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
208
|
+
const DEFAULT_CONFIG_DIR = path.resolve(path.dirname(__filename), '..', '..', 'config');
|
|
209
|
+
|
|
210
|
+
const SEED_ITEMS = [
|
|
211
|
+
{
|
|
212
|
+
text: 'Overview', icon: 'home',
|
|
213
|
+
items: [
|
|
214
|
+
{text: 'Dashboard', url: '#/', icon: 'home'}
|
|
215
|
+
]
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
text: 'Projects', icon: 'folder', permission: 'projects',
|
|
219
|
+
items: [
|
|
220
|
+
{text: 'Manage projects', url: '#/projects', icon: 'folder', permission: 'projects'}
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
text: 'Content', icon: 'edit',
|
|
225
|
+
items: [
|
|
226
|
+
{text: 'Pages', url: '#/pages', icon: 'file-text', permission: 'pages'},
|
|
227
|
+
{text: 'Media', url: '#/media', icon: 'image', permission: 'media'},
|
|
228
|
+
{text: 'Menus', url: '#/menus', icon: 'menu', permission: 'menus'}
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
text: 'Data', icon: 'database',
|
|
233
|
+
items: [
|
|
234
|
+
{text: 'Collections', url: '#/collections', icon: 'database', permission: 'collections'},
|
|
235
|
+
{text: 'Forms', url: '#/forms', icon: 'layout', permission: 'collections'},
|
|
236
|
+
{text: 'Views', url: '#/views', icon: 'eye', permission: 'views'},
|
|
237
|
+
{text: 'Actions', url: '#/actions', icon: 'zap', permission: 'actions'},
|
|
238
|
+
{text: 'Blocks', url: '#/blocks', icon: 'box', permission: 'pages'},
|
|
239
|
+
{text: 'Components', url: '#/components', icon: 'component', permission: 'components'}
|
|
240
|
+
]
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
text: 'System', icon: 'settings',
|
|
244
|
+
items: [
|
|
245
|
+
{text: 'Notifications', url: '#/system/notifications', icon: 'bell', permission: 'notifications'},
|
|
246
|
+
{text: 'Roles', url: '#/roles', icon: 'shield', permission: 'plugins'},
|
|
247
|
+
{text: 'Users', url: '#/users', icon: 'users', permission: 'users'},
|
|
248
|
+
{text: 'Site Settings', url: '#/settings', icon: 'settings', permission: 'settings'},
|
|
249
|
+
{text: 'Effects', url: '#/effects', icon: 'sparkles', permission: 'settings'},
|
|
250
|
+
{text: 'Layouts', url: '#/layouts', icon: 'layout', permission: 'layouts'},
|
|
251
|
+
{text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins'},
|
|
252
|
+
{text: 'My Profile', url: '#/my-profile', icon: 'user'}
|
|
253
|
+
]
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
text: 'Documentation', icon: 'book',
|
|
257
|
+
items: [
|
|
258
|
+
{text: 'Usage', url: '#/documentation', icon: 'book'},
|
|
259
|
+
{text: 'Tutorials', url: '#/tutorials', icon: 'document'},
|
|
260
|
+
{text: 'API Reference', url: '#/api-reference', icon: 'code'}
|
|
261
|
+
]
|
|
262
|
+
}
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
async function exists(p) {
|
|
266
|
+
try { await fs.access(p); return true; } catch { return false; }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function readJson(p) {
|
|
270
|
+
return JSON.parse(await fs.readFile(p, 'utf8'));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function writeJson(p, data) {
|
|
274
|
+
await fs.writeFile(p, JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run the migration. Returns `{migrated: boolean, reason?: string}`.
|
|
279
|
+
*
|
|
280
|
+
* @param {{configDir?: string}} [opts]
|
|
281
|
+
*/
|
|
282
|
+
export async function runMigration(opts = {}) {
|
|
283
|
+
const configDir = opts.configDir || DEFAULT_CONFIG_DIR;
|
|
284
|
+
const menusDir = path.join(configDir, 'menus');
|
|
285
|
+
const menuPath = path.join(menusDir, 'admin-sidebar.json');
|
|
286
|
+
const locPath = path.join(configDir, 'menu-locations.json');
|
|
287
|
+
|
|
288
|
+
if (await exists(menuPath)) {
|
|
289
|
+
return {migrated: false, reason: 'admin-sidebar.json already present'};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await fs.mkdir(menusDir, {recursive: true});
|
|
293
|
+
const now = new Date().toISOString();
|
|
294
|
+
await writeJson(menuPath, {
|
|
295
|
+
slug: 'admin-sidebar',
|
|
296
|
+
name: 'Admin sidebar',
|
|
297
|
+
description: 'Auto-seeded by sidebar migration on first boot',
|
|
298
|
+
items: SEED_ITEMS,
|
|
299
|
+
meta: {createdAt: now, updatedAt: now, bundled: false, presetOwner: null}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
let locations = {};
|
|
303
|
+
if (await exists(locPath)) {
|
|
304
|
+
try { locations = await readJson(locPath); } catch { /* malformed - start fresh */ }
|
|
305
|
+
}
|
|
306
|
+
locations['admin-sidebar'] = 'admin-sidebar';
|
|
307
|
+
await writeJson(locPath, locations);
|
|
308
|
+
|
|
309
|
+
console.log('[admin-sidebar] Seeded admin-sidebar menu + mapped admin-sidebar slot');
|
|
310
|
+
return {migrated: true};
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Step 2.4: Run tests
|
|
315
|
+
|
|
316
|
+
Run: `node --test tests/services/sidebar-migration.test.js`
|
|
317
|
+
|
|
318
|
+
Expected: 4/4 pass.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Task 3: Wire migration into server boot
|
|
323
|
+
|
|
324
|
+
**Files:**
|
|
325
|
+
|
|
326
|
+
- Modify: `server/server.js`
|
|
327
|
+
|
|
328
|
+
### Step 3.1: Find the existing menus migration call
|
|
329
|
+
|
|
330
|
+
Run: `grep -n "runMenusMigration\|menus-migration" server/server.js`
|
|
331
|
+
|
|
332
|
+
### Step 3.2: Add the sidebar migration call
|
|
333
|
+
|
|
334
|
+
Immediately AFTER the existing menus migration block in `server/server.js`, add:
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
try {
|
|
338
|
+
const {runMigration: runSidebarMigration} = await import('./services/sidebar-migration.js');
|
|
339
|
+
await runSidebarMigration();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
app.log.warn(`[admin-sidebar] Migration skipped: ${err.message}`);
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Step 3.3: Verify syntax
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
node --check server/server.js
|
|
349
|
+
node -e "import('./server/services/sidebar-migration.js').then(m => console.log('migration ok:', typeof m.runMigration))"
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Expected: silent + `migration ok: function`.
|
|
353
|
+
|
|
354
|
+
DO NOT restart the server. The migration will fire on the next user-initiated restart.
|
|
355
|
+
|
|
356
|
+
---
|
|
357
|
+
|
|
358
|
+
## Task 4: `registerSidebarItem` plugin hook
|
|
359
|
+
|
|
360
|
+
**Files:**
|
|
361
|
+
|
|
362
|
+
- Modify: `server/services/hooks.js`
|
|
363
|
+
- Modify: `server/services/plugins.js`
|
|
364
|
+
|
|
365
|
+
### Step 4.1: Add the hook export
|
|
366
|
+
|
|
367
|
+
In `server/services/hooks.js`, after the existing `registerMenuLocation` block, add:
|
|
368
|
+
|
|
369
|
+
```js
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// Admin sidebar items - plugin-registered runtime injection
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
const _registeredSidebarItems = [];
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Register a sidebar item from a plugin. Items are merged into the rendered
|
|
378
|
+
* admin sidebar at render time - they are NOT persisted into menu JSON.
|
|
379
|
+
* Plugins re-register on each server boot via their setupPlugin hook.
|
|
380
|
+
*
|
|
381
|
+
* @param {{parent?: string, item: {text: string, url: string, icon?: string, permission?: string, items?: object[]}}} opts
|
|
382
|
+
*/
|
|
383
|
+
export function registerSidebarItem(opts) {
|
|
384
|
+
if (!opts || !opts.item || typeof opts.item.text !== 'string') {
|
|
385
|
+
throw new Error('registerSidebarItem: opts.item.text is required');
|
|
386
|
+
}
|
|
387
|
+
_registeredSidebarItems.push({
|
|
388
|
+
parent: opts.parent || null,
|
|
389
|
+
item: opts.item
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @returns {Array<{parent: string|null, item: object}>}
|
|
395
|
+
*/
|
|
396
|
+
export function getRegisteredSidebarItems() {
|
|
397
|
+
return _registeredSidebarItems.slice();
|
|
398
|
+
}
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Step 4.2: Expose to plugins via the loader
|
|
402
|
+
|
|
403
|
+
In `server/services/plugins.js`, find the line that builds the `hooks` object passed to each plugin's Fastify registration (currently includes `registerMenuLocation`). Update the import + the hooks object:
|
|
404
|
+
|
|
405
|
+
```js
|
|
406
|
+
import {hooks, registerSanitizeRules, registerShortcode, registerTransform, registerMenuLocation, registerSidebarItem} from './hooks.js';
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Then update the `hooks:` block in the plugin register call:
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
hooks: {registerShortcode, registerSanitizeRules, registerTransform, registerMenuLocation, registerSidebarItem, on: hooks.on.bind(hooks)},
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
### Step 4.3: Smoke test
|
|
416
|
+
|
|
417
|
+
Run:
|
|
418
|
+
```bash
|
|
419
|
+
node -e "import('./server/services/hooks.js').then(h => { h.registerSidebarItem({parent: 'system', item: {text: 'X', url: '#/x'}}); console.log(h.getRegisteredSidebarItems()); })"
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Expected: prints `[ { parent: 'system', item: { text: 'X', url: '#/x' } } ]`.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
## Task 5: `/api/sidebar/registered-items` route
|
|
427
|
+
|
|
428
|
+
**Files:**
|
|
429
|
+
|
|
430
|
+
- Create: `server/routes/api/sidebar.js`
|
|
431
|
+
- Create: `tests/routes/sidebar.test.js`
|
|
432
|
+
|
|
433
|
+
### Step 5.1: Write the route test
|
|
434
|
+
|
|
435
|
+
Create `tests/routes/sidebar.test.js`:
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
/**
|
|
439
|
+
* Smoke test for /api/sidebar/registered-items.
|
|
440
|
+
*/
|
|
441
|
+
import {test} from 'node:test';
|
|
442
|
+
import assert from 'node:assert/strict';
|
|
443
|
+
import Fastify from 'fastify';
|
|
444
|
+
import fastifyJwt from '@fastify/jwt';
|
|
445
|
+
import {sidebarRoutes} from '../../server/routes/api/sidebar.js';
|
|
446
|
+
import {registerSidebarItem} from '../../server/services/hooks.js';
|
|
447
|
+
|
|
448
|
+
async function buildApp() {
|
|
449
|
+
const app = Fastify();
|
|
450
|
+
app.register(fastifyJwt, {secret: 'test-secret-only-for-tests-32chars'});
|
|
451
|
+
const authenticate = async (req) => { req.user = {id: 'sa', role: 'super-admin'}; };
|
|
452
|
+
const requirePermission = () => async () => {};
|
|
453
|
+
await app.register(async (inst) => sidebarRoutes(inst, {authenticate, requirePermission}), {prefix: '/api'});
|
|
454
|
+
return app;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
test('GET /api/sidebar/registered-items returns an array', async () => {
|
|
458
|
+
const app = await buildApp();
|
|
459
|
+
const res = await app.inject({method: 'GET', url: '/api/sidebar/registered-items'});
|
|
460
|
+
assert.equal(res.statusCode, 200);
|
|
461
|
+
assert.ok(Array.isArray(res.json()));
|
|
462
|
+
await app.close();
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test('GET /api/sidebar/registered-items reflects registerSidebarItem calls', async () => {
|
|
466
|
+
registerSidebarItem({parent: 'system', item: {text: 'PluginX', url: '#/plugin-x'}});
|
|
467
|
+
const app = await buildApp();
|
|
468
|
+
const res = await app.inject({method: 'GET', url: '/api/sidebar/registered-items'});
|
|
469
|
+
const body = res.json();
|
|
470
|
+
assert.ok(body.some(entry => entry.item.text === 'PluginX'));
|
|
471
|
+
await app.close();
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Step 5.2: Run to confirm failure
|
|
476
|
+
|
|
477
|
+
Run: `node --test tests/routes/sidebar.test.js`
|
|
478
|
+
|
|
479
|
+
Expected: import error.
|
|
480
|
+
|
|
481
|
+
### Step 5.3: Implement the route
|
|
482
|
+
|
|
483
|
+
Create `server/routes/api/sidebar.js`:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
/**
|
|
487
|
+
* Admin sidebar API
|
|
488
|
+
*
|
|
489
|
+
* GET /api/sidebar/registered-items - plugin-registered sidebar items
|
|
490
|
+
*
|
|
491
|
+
* Auth middlewares are accepted as DI options so tests can supply no-ops.
|
|
492
|
+
*/
|
|
493
|
+
import {
|
|
494
|
+
authenticate as defaultAuthenticate,
|
|
495
|
+
requirePermission as defaultRequirePermission
|
|
496
|
+
} from '../../middleware/auth.js';
|
|
497
|
+
import {getRegisteredSidebarItems} from '../../services/hooks.js';
|
|
498
|
+
|
|
499
|
+
export async function sidebarRoutes(fastify, opts = {}) {
|
|
500
|
+
const authenticate = opts.authenticate || defaultAuthenticate;
|
|
501
|
+
const requirePermission = opts.requirePermission || defaultRequirePermission;
|
|
502
|
+
|
|
503
|
+
const canRead = {preHandler: [authenticate, requirePermission('menus', 'read')]};
|
|
504
|
+
|
|
505
|
+
fastify.get('/sidebar/registered-items', canRead, async () => {
|
|
506
|
+
return getRegisteredSidebarItems();
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Step 5.4: Run tests
|
|
512
|
+
|
|
513
|
+
Run: `node --test tests/routes/sidebar.test.js`
|
|
514
|
+
|
|
515
|
+
Expected: 2/2 pass.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
## Task 6: Wire `/api/sidebar/*` route into server.js
|
|
520
|
+
|
|
521
|
+
**Files:**
|
|
522
|
+
|
|
523
|
+
- Modify: `server/server.js`
|
|
524
|
+
|
|
525
|
+
### Step 6.1: Add import + register
|
|
526
|
+
|
|
527
|
+
In `server/server.js`, alongside the existing menus route registration:
|
|
528
|
+
|
|
529
|
+
```js
|
|
530
|
+
const {sidebarRoutes} = await import('./routes/api/sidebar.js');
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
Then register:
|
|
534
|
+
|
|
535
|
+
```js
|
|
536
|
+
await app.register(sidebarRoutes, {prefix: '/api'});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Step 6.2: Verify
|
|
540
|
+
|
|
541
|
+
```bash
|
|
542
|
+
node --check server/server.js
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Expected: silent.
|
|
546
|
+
|
|
547
|
+
DO NOT restart the server.
|
|
548
|
+
|
|
549
|
+
---
|
|
550
|
+
|
|
551
|
+
## Task 7: Sidebar renderer module
|
|
552
|
+
|
|
553
|
+
**Files:**
|
|
554
|
+
|
|
555
|
+
- Create: `admin/js/lib/sidebar-renderer.js`
|
|
556
|
+
|
|
557
|
+
This is the heart of the new UX. Async render, fetches menu + projects + plugin items, merges, filters, emits nested `<details>` markup, and inserts via `replaceChildren` + `createContextualFragment` (modern safe DOM API - no `el.innerHTML =` assignments).
|
|
558
|
+
|
|
559
|
+
### Step 7.1: Create the renderer
|
|
560
|
+
|
|
561
|
+
Create `admin/js/lib/sidebar-renderer.js`:
|
|
562
|
+
|
|
563
|
+
```js
|
|
564
|
+
/**
|
|
565
|
+
* Admin sidebar renderer.
|
|
566
|
+
*
|
|
567
|
+
* Replaces Domma.elements.sidebar(...) for the admin panel. Reads sidebar
|
|
568
|
+
* structure from the Menus system (slot `admin-sidebar`), merges plugin-
|
|
569
|
+
* registered items, synthesises per-project sub-trees, filters by user
|
|
570
|
+
* permission, and renders nested <details>/<summary> for expand/collapse.
|
|
571
|
+
*
|
|
572
|
+
* All user-supplied strings are escaped via esc() before being interpolated
|
|
573
|
+
* into the rendered markup. Markup is inserted using
|
|
574
|
+
* Range#createContextualFragment + Element#replaceChildren (modern safe DOM
|
|
575
|
+
* API) rather than innerHTML assignment.
|
|
576
|
+
*
|
|
577
|
+
* Fallback when no admin-sidebar menu is mapped or available: render a
|
|
578
|
+
* minimal Dashboard + Menus tree so the admin can always escape a broken
|
|
579
|
+
* state.
|
|
580
|
+
*/
|
|
581
|
+
import {api} from '../api.js';
|
|
582
|
+
|
|
583
|
+
function esc(s) {
|
|
584
|
+
return String(s == null ? '' : s)
|
|
585
|
+
.replace(/&/g, '&').replace(/</g, '<')
|
|
586
|
+
.replace(/>/g, '>').replace(/"/g, '"');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const FALLBACK_TREE = [
|
|
590
|
+
{text: 'Dashboard', url: '#/', icon: 'home'},
|
|
591
|
+
{text: 'Menus', url: '#/menus', icon: 'menu'}
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
function canAny(permissions, resource) {
|
|
595
|
+
if (!resource) return true;
|
|
596
|
+
if (!permissions || !permissions.length) return false;
|
|
597
|
+
if (permissions.includes(resource)) return true;
|
|
598
|
+
return ['read', 'create', 'update', 'delete'].some(a => permissions.includes(`${resource}.${a}`));
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function filterByPermission(items, permissions) {
|
|
602
|
+
const out = [];
|
|
603
|
+
for (const item of items) {
|
|
604
|
+
if (item.hidden) continue;
|
|
605
|
+
if (item.permission && !canAny(permissions, item.permission)) continue;
|
|
606
|
+
const children = Array.isArray(item.items) && item.items.length
|
|
607
|
+
? filterByPermission(item.items, permissions)
|
|
608
|
+
: [];
|
|
609
|
+
out.push({...item, items: children});
|
|
610
|
+
}
|
|
611
|
+
return out;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function mergePluginItems(tree, registered) {
|
|
615
|
+
if (!registered || !registered.length) return tree;
|
|
616
|
+
const findParent = (xs, parentText) => {
|
|
617
|
+
for (const node of xs) {
|
|
618
|
+
if ((node.text || '').toLowerCase() === parentText.toLowerCase()) return node;
|
|
619
|
+
if (Array.isArray(node.items)) {
|
|
620
|
+
const found = findParent(node.items, parentText);
|
|
621
|
+
if (found) return found;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return null;
|
|
625
|
+
};
|
|
626
|
+
for (const reg of registered) {
|
|
627
|
+
if (reg.parent) {
|
|
628
|
+
const parent = findParent(tree, reg.parent);
|
|
629
|
+
if (parent) {
|
|
630
|
+
parent.items = Array.isArray(parent.items) ? parent.items : [];
|
|
631
|
+
parent.items.push(reg.item);
|
|
632
|
+
} else {
|
|
633
|
+
tree.push(reg.item);
|
|
634
|
+
}
|
|
635
|
+
} else {
|
|
636
|
+
tree.push(reg.item);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return tree;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
async function buildProjectsSubtree(permissions) {
|
|
643
|
+
if (!canAny(permissions, 'projects')) return [];
|
|
644
|
+
let projects = [];
|
|
645
|
+
try { projects = await api.projects.list(); } catch { return []; }
|
|
646
|
+
const countsByProject = {};
|
|
647
|
+
await Promise.all(projects.map(async p => {
|
|
648
|
+
try {
|
|
649
|
+
const grouped = await api.projects.artefacts(p.slug);
|
|
650
|
+
countsByProject[p.slug] = grouped;
|
|
651
|
+
} catch { countsByProject[p.slug] = {}; }
|
|
652
|
+
}));
|
|
653
|
+
|
|
654
|
+
const TYPE_DEFS = [
|
|
655
|
+
{key: 'pages', text: 'Pages', icon: 'file-text', path: '/pages'},
|
|
656
|
+
{key: 'collections', text: 'Collections', icon: 'database', path: '/collections'},
|
|
657
|
+
{key: 'forms', text: 'Forms', icon: 'layout', path: '/forms'},
|
|
658
|
+
{key: 'actions', text: 'Actions', icon: 'zap', path: '/actions'},
|
|
659
|
+
{key: 'menus', text: 'Menus', icon: 'menu', path: '/menus'},
|
|
660
|
+
{key: 'blocks', text: 'Blocks', icon: 'box', path: '/blocks'},
|
|
661
|
+
{key: 'views', text: 'Views', icon: 'eye', path: '/views'},
|
|
662
|
+
{key: 'roles', text: 'Roles', icon: 'shield', path: '/roles'},
|
|
663
|
+
{key: 'users', text: 'Users', icon: 'users', path: '/users'}
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
return projects.map(p => {
|
|
667
|
+
const slugEnc = encodeURIComponent(p.slug);
|
|
668
|
+
const base = '#/projects/' + slugEnc;
|
|
669
|
+
const counts = countsByProject[p.slug] || {};
|
|
670
|
+
const children = [
|
|
671
|
+
{text: 'Overview', url: base, icon: p.icon || 'folder'}
|
|
672
|
+
];
|
|
673
|
+
for (const def of TYPE_DEFS) {
|
|
674
|
+
const arr = counts[def.key];
|
|
675
|
+
const n = Array.isArray(arr) ? arr.length : 0;
|
|
676
|
+
if (n <= 0) continue;
|
|
677
|
+
children.push({text: `${def.text} (${n})`, url: base + def.path, icon: def.icon});
|
|
678
|
+
}
|
|
679
|
+
children.push({text: 'Settings', url: base + '/settings', icon: 'settings'});
|
|
680
|
+
return {
|
|
681
|
+
text: p.name || p.slug,
|
|
682
|
+
icon: p.icon || 'folder',
|
|
683
|
+
items: children
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function injectProjectsSubtree(tree, projectChildren) {
|
|
689
|
+
for (const node of tree) {
|
|
690
|
+
if ((node.text || '').toLowerCase() === 'projects') {
|
|
691
|
+
node.items = Array.isArray(node.items) ? node.items.slice() : [];
|
|
692
|
+
node.items.push(...projectChildren);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function renderItem(item) {
|
|
699
|
+
const icon = item.icon ? `<span data-icon="${esc(item.icon)}"></span>` : '';
|
|
700
|
+
const text = esc(item.text || '');
|
|
701
|
+
const url = item.url ? esc(item.url) : '';
|
|
702
|
+
const hasChildren = Array.isArray(item.items) && item.items.length > 0;
|
|
703
|
+
|
|
704
|
+
if (hasChildren) {
|
|
705
|
+
const stateKey = `sidebar.expanded.${(item.text || '').toLowerCase().replace(/\s+/g, '-')}`;
|
|
706
|
+
const isOpen = S.get(stateKey) !== false;
|
|
707
|
+
const childHtml = item.items.map(c => renderItem(c)).join('');
|
|
708
|
+
return `<details data-state-key="${esc(stateKey)}"${isOpen ? ' open' : ''}>
|
|
709
|
+
<summary>${icon} <span class="sidebar-text">${text}</span></summary>
|
|
710
|
+
<div>${childHtml}</div>
|
|
711
|
+
</details>`;
|
|
712
|
+
}
|
|
713
|
+
return `<a href="${url}" class="sidebar-link" data-url="${url}">${icon} <span class="sidebar-text">${text}</span></a>`;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Render the admin sidebar into the given mount selector.
|
|
718
|
+
*
|
|
719
|
+
* @param {{mount: string, permissions: string[]}} opts
|
|
720
|
+
*/
|
|
721
|
+
export async function renderAdminSidebar({mount, permissions}) {
|
|
722
|
+
const mountEl = $(mount).get(0);
|
|
723
|
+
if (!mountEl) return;
|
|
724
|
+
|
|
725
|
+
let tree;
|
|
726
|
+
try {
|
|
727
|
+
const menu = await api.menus.get('admin-sidebar');
|
|
728
|
+
tree = Array.isArray(menu?.items) ? menu.items : null;
|
|
729
|
+
} catch { tree = null; }
|
|
730
|
+
|
|
731
|
+
if (!tree || !tree.length) {
|
|
732
|
+
console.warn('[admin-sidebar] No admin-sidebar menu found; using fallback tree');
|
|
733
|
+
tree = FALLBACK_TREE.slice();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
let registered = [];
|
|
737
|
+
try {
|
|
738
|
+
registered = await H.get('/api/sidebar/registered-items') || [];
|
|
739
|
+
} catch { /* ignore - render without plugin items */ }
|
|
740
|
+
tree = mergePluginItems(tree, registered);
|
|
741
|
+
|
|
742
|
+
const projectsBranch = await buildProjectsSubtree(permissions);
|
|
743
|
+
if (projectsBranch.length) injectProjectsSubtree(tree, projectsBranch);
|
|
744
|
+
|
|
745
|
+
tree = filterByPermission(tree, permissions);
|
|
746
|
+
|
|
747
|
+
// Build the markup string from escaped components, then insert via
|
|
748
|
+
// safe DOM API (replaceChildren + createContextualFragment) - no
|
|
749
|
+
// direct innerHTML assignment.
|
|
750
|
+
const markup = `<nav class="dm-admin-sidebar">${tree.map(renderItem).join('')}</nav>`;
|
|
751
|
+
const range = document.createRange();
|
|
752
|
+
range.selectNodeContents(mountEl);
|
|
753
|
+
const fragment = range.createContextualFragment(markup);
|
|
754
|
+
mountEl.replaceChildren(fragment);
|
|
755
|
+
|
|
756
|
+
Domma.icons.scan(mountEl);
|
|
757
|
+
|
|
758
|
+
// Persist expand/collapse state
|
|
759
|
+
$(mountEl).off('toggle', 'details').on('toggle', 'details', function() {
|
|
760
|
+
const key = $(this).attr('data-state-key');
|
|
761
|
+
if (key) S.set(key, this.open);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
// Active item indicator
|
|
765
|
+
function setActive() {
|
|
766
|
+
const current = location.hash || '#/';
|
|
767
|
+
$(mountEl).find('.sidebar-link').removeClass('active');
|
|
768
|
+
$(mountEl).find(`.sidebar-link[data-url="${current}"]`).addClass('active');
|
|
769
|
+
}
|
|
770
|
+
setActive();
|
|
771
|
+
M.subscribe('router:afterChange', setActive);
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
### Step 7.2: Verify syntax
|
|
776
|
+
|
|
777
|
+
Run: `node --check admin/js/lib/sidebar-renderer.js`
|
|
778
|
+
|
|
779
|
+
Expected: silent.
|
|
780
|
+
|
|
781
|
+
---
|
|
782
|
+
|
|
783
|
+
## Task 8: Replace the sidebar call in `app.js`
|
|
784
|
+
|
|
785
|
+
**Files:**
|
|
786
|
+
|
|
787
|
+
- Modify: `admin/js/app.js`
|
|
788
|
+
|
|
789
|
+
### Step 8.1: Find the existing call
|
|
790
|
+
|
|
791
|
+
Run: `grep -n "Domma.elements.sidebar\|getSidebarConfig" admin/js/app.js | head -5`
|
|
792
|
+
|
|
793
|
+
### Step 8.2: Replace the sidebar build
|
|
794
|
+
|
|
795
|
+
In `admin/js/app.js`, find `buildSidebar(permissions)`. Replace its body with the new renderer call:
|
|
796
|
+
|
|
797
|
+
```js
|
|
798
|
+
async function buildSidebar(permissions) {
|
|
799
|
+
const {renderAdminSidebar} = await import('./lib/sidebar-renderer.js');
|
|
800
|
+
await renderAdminSidebar({
|
|
801
|
+
mount: '#admin-sidebar',
|
|
802
|
+
permissions
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
Remove the now-unused: `getSidebarConfig` import (it'll be a stub from Task 9), and any count/project fetches that exclusively fed the old sidebar build (the renderer fetches its own data).
|
|
808
|
+
|
|
809
|
+
Keep:
|
|
810
|
+
- The auth guard
|
|
811
|
+
- The router setup
|
|
812
|
+
- All `router:afterChange` subscribers used by other admin features
|
|
813
|
+
|
|
814
|
+
If `fetchSidebarCounts` is used elsewhere (badge rendering on non-sidebar UI), leave it.
|
|
815
|
+
|
|
816
|
+
### Step 8.3: Verify syntax
|
|
817
|
+
|
|
818
|
+
Run: `node --check admin/js/app.js`
|
|
819
|
+
|
|
820
|
+
Expected: silent.
|
|
821
|
+
|
|
822
|
+
### Step 8.4: Browser smoke test required
|
|
823
|
+
|
|
824
|
+
State explicitly: cannot drive the browser. A real browser load is required to confirm:
|
|
825
|
+
- Sidebar renders with the seeded tree (after migration runs on next server restart)
|
|
826
|
+
- Top-level sections (Overview, Projects, Content, Data, System, Documentation) appear
|
|
827
|
+
- Each section is collapsible via the chevron / click on summary
|
|
828
|
+
- Permission-gated items hide for users lacking the permission
|
|
829
|
+
|
|
830
|
+
---
|
|
831
|
+
|
|
832
|
+
## Task 9: Deprecate `sidebar-config.js`
|
|
833
|
+
|
|
834
|
+
**Files:**
|
|
835
|
+
|
|
836
|
+
- Modify: `admin/js/config/sidebar-config.js`
|
|
837
|
+
|
|
838
|
+
### Step 9.1: Gut the file
|
|
839
|
+
|
|
840
|
+
Replace the entire contents of `admin/js/config/sidebar-config.js` with:
|
|
841
|
+
|
|
842
|
+
```js
|
|
843
|
+
/**
|
|
844
|
+
* DEPRECATED - kept as a stub for one release.
|
|
845
|
+
*
|
|
846
|
+
* The admin sidebar is now rendered from the Menus system at the
|
|
847
|
+
* `admin-sidebar` slot. Edit `config/menus/admin-sidebar.json` (or use the
|
|
848
|
+
* Menus admin) to change the sidebar.
|
|
849
|
+
*
|
|
850
|
+
* If you're calling this function for sidebar content, switch to:
|
|
851
|
+
* import {renderAdminSidebar} from '../lib/sidebar-renderer.js';
|
|
852
|
+
*
|
|
853
|
+
* This stub returns an empty array so any straggling callers still work.
|
|
854
|
+
*/
|
|
855
|
+
export function getSidebarConfig() {
|
|
856
|
+
console.warn('[sidebar-config] getSidebarConfig() is deprecated. Use renderAdminSidebar() from lib/sidebar-renderer.js.');
|
|
857
|
+
return [];
|
|
858
|
+
}
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
### Step 9.2: Verify syntax
|
|
862
|
+
|
|
863
|
+
Run: `node --check admin/js/config/sidebar-config.js`
|
|
864
|
+
|
|
865
|
+
Expected: silent.
|
|
866
|
+
|
|
867
|
+
---
|
|
868
|
+
|
|
869
|
+
## Task 10: Add `permission` field to menu-editor item rows
|
|
870
|
+
|
|
871
|
+
**Files:**
|
|
872
|
+
|
|
873
|
+
- Modify: `admin/js/views/menu-editor.js`
|
|
874
|
+
|
|
875
|
+
### Step 10.1: Find the item-row template
|
|
876
|
+
|
|
877
|
+
Run: `grep -n "menu-tree-row\|me-text\|me-url\|me-icon\|me-vis" admin/js/views/menu-editor.js | head -10`
|
|
878
|
+
|
|
879
|
+
The current item row has: text, url, icon, visibility, indent/outdent, move-up/down, hide, remove.
|
|
880
|
+
|
|
881
|
+
### Step 10.2: Add permission select column
|
|
882
|
+
|
|
883
|
+
The existing item row template uses these classes for its inputs (added in the Menus plan T20): `.me-text`, `.me-url`, `.me-icon`, `.me-vis` (visibility text input). The new `.me-perm` select sits right after `.me-vis`.
|
|
884
|
+
|
|
885
|
+
Three edits to `menu-editor.js`:
|
|
886
|
+
|
|
887
|
+
**(a) At the top of `onMount`, fetch the permissions registry once:**
|
|
888
|
+
|
|
889
|
+
```js
|
|
890
|
+
const permRegistry = await H.get('/api/auth/permissions-registry').catch(() => ({resources: []}));
|
|
891
|
+
const permOptions = ['<option value="">- no permission gate -</option>']
|
|
892
|
+
.concat((permRegistry.resources || []).map(r => `<option value="${r.key}">${r.label || r.key}</option>`));
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
**(b) In the row template (the function or template literal that builds each `.menu-tree-row`), insert AFTER the `<input class="me-vis" …>` line and BEFORE the indent/outdent buttons:**
|
|
896
|
+
|
|
897
|
+
```js
|
|
898
|
+
<select class="form-select me-perm" data-id="${row.id}">
|
|
899
|
+
${permOptions.map(opt => opt.replace(`value="${row.permission || ''}"`, `value="${row.permission || ''}" selected`)).join('')}
|
|
900
|
+
</select>
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
(If pre-selection via string replacement feels brittle, build the options inline with a `${row.permission === r.key ? ' selected' : ''}` conditional - same effect.)
|
|
904
|
+
|
|
905
|
+
**(c) In `flatten(items)`** (the function that turns the nested-array form into a flat list of rows), include `permission` on each row object:
|
|
906
|
+
|
|
907
|
+
```js
|
|
908
|
+
out.push({
|
|
909
|
+
id, parentId,
|
|
910
|
+
text: it.text || '',
|
|
911
|
+
url: it.url || '',
|
|
912
|
+
icon: it.icon || '',
|
|
913
|
+
visibility: it.visibility || '',
|
|
914
|
+
permission: it.permission || '',
|
|
915
|
+
hidden: !!it.hidden,
|
|
916
|
+
bundled: !!it.bundled
|
|
917
|
+
});
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
**(d) In `nest(flat)`** (the function that builds the persisted nested shape on save), include `permission` when truthy:
|
|
921
|
+
|
|
922
|
+
```js
|
|
923
|
+
return {
|
|
924
|
+
text: r.text.trim(),
|
|
925
|
+
url: r.url.trim(),
|
|
926
|
+
...(r.icon && {icon: r.icon.trim()}),
|
|
927
|
+
...(r.visibility && {visibility: r.visibility}),
|
|
928
|
+
...(r.permission && {permission: r.permission}),
|
|
929
|
+
...(r.hidden && {hidden: true}),
|
|
930
|
+
...(r.bundled && {bundled: true}),
|
|
931
|
+
...(children.length && {items: children})
|
|
932
|
+
};
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
**(e) In `syncRowsFromDom` (or equivalent - the function that reads input values back into the flat array on Save), read the new select:**
|
|
936
|
+
|
|
937
|
+
```js
|
|
938
|
+
row.permission = $(this).find('.me-perm').val();
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### Step 10.3: Verify syntax
|
|
942
|
+
|
|
943
|
+
Run: `node --check admin/js/views/menu-editor.js`
|
|
944
|
+
|
|
945
|
+
Expected: silent.
|
|
946
|
+
|
|
947
|
+
### Step 10.4: Browser smoke test required
|
|
948
|
+
|
|
949
|
+
State explicitly - cannot drive. A real browser test required to confirm:
|
|
950
|
+
- Editing the `admin-sidebar` menu shows a permission select on each item
|
|
951
|
+
- Selecting a permission and saving persists `permission: '<key>'` in the JSON
|
|
952
|
+
- Clearing the select removes the field
|
|
953
|
+
|
|
954
|
+
---
|
|
955
|
+
|
|
956
|
+
## Task 11: CSS for nested `<details>` sidebar
|
|
957
|
+
|
|
958
|
+
**Files:**
|
|
959
|
+
|
|
960
|
+
- Modify: `admin/css/admin.css`
|
|
961
|
+
|
|
962
|
+
### Step 11.1: Append the styles
|
|
963
|
+
|
|
964
|
+
Append to `admin/css/admin.css`:
|
|
965
|
+
|
|
966
|
+
```css
|
|
967
|
+
/* Admin sidebar - nested details-based tree */
|
|
968
|
+
.dm-admin-sidebar {
|
|
969
|
+
display: flex;
|
|
970
|
+
flex-direction: column;
|
|
971
|
+
gap: 2px;
|
|
972
|
+
padding: 8px 0;
|
|
973
|
+
}
|
|
974
|
+
.dm-admin-sidebar details {
|
|
975
|
+
margin: 0;
|
|
976
|
+
}
|
|
977
|
+
.dm-admin-sidebar details > summary {
|
|
978
|
+
list-style: none;
|
|
979
|
+
cursor: pointer;
|
|
980
|
+
display: flex;
|
|
981
|
+
align-items: center;
|
|
982
|
+
gap: 0.5rem;
|
|
983
|
+
padding: 0.4rem 0.75rem;
|
|
984
|
+
border-radius: 4px;
|
|
985
|
+
user-select: none;
|
|
986
|
+
}
|
|
987
|
+
.dm-admin-sidebar details > summary::-webkit-details-marker { display: none; }
|
|
988
|
+
.dm-admin-sidebar details > summary::after {
|
|
989
|
+
content: '\25B8';
|
|
990
|
+
margin-left: auto;
|
|
991
|
+
transition: transform 0.15s ease;
|
|
992
|
+
color: var(--dm-text-muted, #999);
|
|
993
|
+
font-size: 0.85em;
|
|
994
|
+
}
|
|
995
|
+
.dm-admin-sidebar details[open] > summary::after { transform: rotate(90deg); }
|
|
996
|
+
.dm-admin-sidebar details > div {
|
|
997
|
+
padding-left: 1rem;
|
|
998
|
+
border-left: 1px solid var(--dm-border-subtle, rgba(0, 0, 0, 0.08));
|
|
999
|
+
margin-left: 0.95rem;
|
|
1000
|
+
}
|
|
1001
|
+
.dm-admin-sidebar .sidebar-link {
|
|
1002
|
+
display: flex;
|
|
1003
|
+
align-items: center;
|
|
1004
|
+
gap: 0.5rem;
|
|
1005
|
+
padding: 0.35rem 0.75rem;
|
|
1006
|
+
text-decoration: none;
|
|
1007
|
+
color: var(--dm-text, inherit);
|
|
1008
|
+
border-radius: 4px;
|
|
1009
|
+
}
|
|
1010
|
+
.dm-admin-sidebar .sidebar-link:hover,
|
|
1011
|
+
.dm-admin-sidebar details > summary:hover {
|
|
1012
|
+
background: var(--dm-bg-subtle, rgba(0, 0, 0, 0.05));
|
|
1013
|
+
}
|
|
1014
|
+
.dm-admin-sidebar .sidebar-link.active {
|
|
1015
|
+
background: var(--dm-accent-bg, rgba(0, 0, 0, 0.08));
|
|
1016
|
+
font-weight: 600;
|
|
1017
|
+
}
|
|
1018
|
+
.dm-admin-sidebar .sidebar-text {
|
|
1019
|
+
flex: 1;
|
|
1020
|
+
min-width: 0;
|
|
1021
|
+
overflow: hidden;
|
|
1022
|
+
text-overflow: ellipsis;
|
|
1023
|
+
white-space: nowrap;
|
|
1024
|
+
}
|
|
1025
|
+
.dm-admin-sidebar [data-icon] {
|
|
1026
|
+
flex-shrink: 0;
|
|
1027
|
+
}
|
|
1028
|
+
```
|
|
1029
|
+
|
|
1030
|
+
### Step 11.2: Verify
|
|
1031
|
+
|
|
1032
|
+
Open `admin/css/admin.css` - confirm the appended block has matched braces. (No node check for CSS.)
|
|
1033
|
+
|
|
1034
|
+
---
|
|
1035
|
+
|
|
1036
|
+
## Task 12: Documentation updates
|
|
1037
|
+
|
|
1038
|
+
**Files:**
|
|
1039
|
+
|
|
1040
|
+
- Modify: `docs/configuration.md`
|
|
1041
|
+
- Modify: `docs/plugin-development.md`
|
|
1042
|
+
- Modify: `docs/api-reference.md`
|
|
1043
|
+
- Modify: `CLAUDE.md`
|
|
1044
|
+
|
|
1045
|
+
### Step 12.1: `docs/configuration.md`
|
|
1046
|
+
|
|
1047
|
+
In the existing Menus section, document:
|
|
1048
|
+
- The new core slot `admin-sidebar`
|
|
1049
|
+
- The `permission` field on menu items (resource key from permissionRegistry)
|
|
1050
|
+
- Where the auto-seeded `admin-sidebar.json` lives + the auto-migration
|
|
1051
|
+
|
|
1052
|
+
### Step 12.2: `docs/plugin-development.md`
|
|
1053
|
+
|
|
1054
|
+
In the hooks reference, add `hooks.registerSidebarItem({parent, item})`. Include the option shape (parent string + item shape) and the runtime behaviour (items are not persisted; plugins re-register on each boot via setupPlugin).
|
|
1055
|
+
|
|
1056
|
+
### Step 12.3: `docs/api-reference.md`
|
|
1057
|
+
|
|
1058
|
+
Add `GET /api/sidebar/registered-items` - returns the array of `{parent, item}` entries currently registered. Requires `menus.read` permission.
|
|
1059
|
+
|
|
1060
|
+
### Step 12.4: `CLAUDE.md`
|
|
1061
|
+
|
|
1062
|
+
Add a short note that the admin sidebar is now menu-data-driven (the menu lives at `config/menus/admin-sidebar.json` and renders via `admin/js/lib/sidebar-renderer.js`).
|
|
1063
|
+
|
|
1064
|
+
---
|
|
1065
|
+
|
|
1066
|
+
## Task 13: Final verification + handoff
|
|
1067
|
+
|
|
1068
|
+
**Files:** none (verification only)
|
|
1069
|
+
|
|
1070
|
+
### Step 13.1: Run the full feature test suite
|
|
1071
|
+
|
|
1072
|
+
```bash
|
|
1073
|
+
node --test \
|
|
1074
|
+
tests/services/menus.test.js \
|
|
1075
|
+
tests/services/sidebar-migration.test.js \
|
|
1076
|
+
tests/routes/sidebar.test.js \
|
|
1077
|
+
tests/services/projects.test.js \
|
|
1078
|
+
tests/routes/projects.test.js \
|
|
1079
|
+
tests/services/scaffolder.test.js 2>&1 | tail -10
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
Expected: all pass.
|
|
1083
|
+
|
|
1084
|
+
### Step 13.2: Curl smoke
|
|
1085
|
+
|
|
1086
|
+
```bash
|
|
1087
|
+
curl -s -o /dev/null -w "GET /api/sidebar/registered-items: %{http_code}\n" http://localhost:4096/api/sidebar/registered-items
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
Expected (after the user restarts the server): `401`. If still `404`, the user hasn't restarted yet - that's fine, the route will activate on next restart.
|
|
1091
|
+
|
|
1092
|
+
### Step 13.3: Browser walkthrough (required - user-driven)
|
|
1093
|
+
|
|
1094
|
+
The user MUST hard-refresh the admin and walk through:
|
|
1095
|
+
|
|
1096
|
+
1. Sidebar renders with Overview / Projects / Content / Data / System / Documentation as collapsible top-level sections (the chevron rotates on toggle).
|
|
1097
|
+
2. Permission-gated items hide for users without the permission (test by creating a user with limited role).
|
|
1098
|
+
3. Projects parent expands to show per-project sub-trees (Overview + populated type rows + Settings).
|
|
1099
|
+
4. Editing the `admin-sidebar` menu from `#/menus/edit/admin-sidebar` works - the permission select appears on each item.
|
|
1100
|
+
5. Re-ordering items in the menu editor reorders the sidebar.
|
|
1101
|
+
6. Plugin-registered items (if any are installed) appear under their declared parent.
|
|
1102
|
+
|
|
1103
|
+
State explicitly: this is the real test. Server-side tests can't verify the renderer or the visual shape.
|
|
1104
|
+
|
|
1105
|
+
### Step 13.4: Hand off
|
|
1106
|
+
|
|
1107
|
+
Do NOT commit. Summarise changes for the user to commit. Suggested commit message:
|
|
1108
|
+
|
|
1109
|
+
> Admin sidebar now rendered from the Menus system at the `admin-sidebar` slot -
|
|
1110
|
+
> auto-migrates the current sidebar tree on first boot, supports per-item
|
|
1111
|
+
> permission gating, native <details> expand/collapse, and a registerSidebarItem
|
|
1112
|
+
> hook for plugin extensions. The Menus admin can edit the sidebar visually.
|