Skip to content

feat(menu-plugin): DB-driven admin sidebar navigation manager#989

Open
lane711 wants to merge 31 commits into
mainfrom
lane711/menu-plugin-plan
Open

feat(menu-plugin): DB-driven admin sidebar navigation manager#989
lane711 wants to merge 31 commits into
mainfrom
lane711/menu-plugin-plan

Conversation

@lane711

@lane711 lane711 commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a Menu Manager plugin that replaces the hardcoded admin sidebar with a DB-driven, user-customizable navigation tree
  • Plugin is off by default; when activated it seeds system + plugin menu items and respects plugin active/inactive status for visibility
  • Adds a settingsTabContent extension point to the plugin SDK so plugins can render custom settings tabs without hardcoded per-plugin renderers

Changes

Core

  • packages/core/src/plugins/core-plugins/menu-plugin/ — new plugin with full CRUD admin UI, drag-to-reorder, visibility toggles, parent/child nesting
  • packages/core/src/middleware/menu.ts — post-render HTML replacement of sidebar markers; regex-replaces both desktop + mobile instances; off by default until plugin activated
  • packages/core/src/app.ts — registers menuMiddleware before all admin routes (Hono ordering fix)
  • packages/core/src/plugins/sdk.ts — adds settingsTabContent: { loadData, render } extension point
  • packages/core/src/templates/pages/admin-plugin-settings.template.ts — renders plugin-registered settings tab content

Menu Plugin Behavior

  • System items (Content, Collections, Users, Settings, Plugins, Menu) seed with visible=true
  • Plugin items seed with visible=false when plugin is inactive; visible=true when active
  • visible flag exclusively controls sidebar visibility; toggling a plugin's active state auto-updates visibility on next boot
  • Parent field always editable (removed parent from lockedFields); url stays locked for system/plugin items
  • Row click navigates to edit; delete uses POST /:id/delete (HTML form compatible)

Testing

  • Reset DB, activate Menu plugin, verify only active plugins appear in sidebar
  • Edit a menu item (label, parent, visible) — saves correctly
  • Delete a user-created menu item
  • Disable a plugin — its sidebar link disappears on next page load
  • Disable Menu plugin — sidebar reverts to hardcoded fallback

🤖 Generated with Claude Code

lane711 and others added 30 commits June 22, 2026 14:20
Replaces hardcoded sidebar nav with document-model-backed menu items.
Plugins and admins can contribute, reorder, and toggle nav entries without
code changes; four system items (Content, Collections, Users, Settings) are
seeded idempotently on first boot.

Key pieces:
- `core-menu` plugin: registers `menu_item` document type with q_* generated
  columns, seeds system items via `upsertSystemItem`, reconciles plugin-declared
  menu entries via `reconcileMenuFromPlugins`
- `menu-repository.ts`: CRUD layer (raw D1 SQL, tenant-scoped, R1/R3 compliant)
- `menu.ts` middleware: post-response HTML block replacement swaps
  `<!-- ADMIN_SIDEBAR_NAV_ITEMS -->` marker with data-driven items; falls back
  to hardcoded items if DB unavailable
- Admin routes at `/admin/menu`: list, create/edit/delete user items,
  visibility toggle, move-up/move-down reorder, locked-field enforcement
- `menu-icons.ts`: consolidated 30-icon map + `resolveIcon` helper
- 7 real-SQLite unit tests (menu-repository.sqlite.test.ts)
- E2E spec 82-menu-management.spec.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
menuPlugin is no longer auto-registered. Developers enable it by adding
it to config.plugins.register in their app entry point. menuMiddleware is
now mounted inside the plugin's register() so it only runs when the plugin
is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ran generate-plugin-registry.mjs to pick up the new menu-plugin manifest.json.
core-menu now appears in the /admin/plugins list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…in page

- Rename plugin id core-menu → menu (no 'core' prefix)
- Re-add to corePluginsBeforeCatchAll so /admin/menu routes always mount
- Regenerate manifest-registry.ts to pick up renamed id
- /admin/plugins/menu redirects to /admin/menu (the actual editor)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
adminMenu: null in manifest stops pluginMenuMiddleware from injecting a
second 'Menu' link via <!-- DYNAMIC_PLUGIN_MENU -->. Added 'Menu' as a
5th system seed item (sortOrder 50) so it still appears in the
data-driven sidebar managed by menuMiddleware.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ed plugin DB records

- Add 'Menu' item to baseMenuItems in admin-layout-catalyst so the link
  always shows even before menu_item docs are seeded (hardcoded fallback)
- Filter installed plugins whose IDs are absent from PLUGIN_REGISTRY
  (e.g. stale 'core-menu' DB records after rename to 'menu') so they
  no longer cause duplicates on the plugins list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
menuMiddleware checks the plugins table for the 'menu' plugin status
before replacing the sidebar. If status is 'inactive' (disabled via
admin UI), the hardcoded sidebar fallback is used instead. Treats
missing DB record as active so initial seeding still works.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… plugins table

PluginService stores plugins in documents (type_id='plugin', slug=id),
not the legacy Drizzle-schema plugins table. The previous query always
returned null → fell back to true → sidebar never reverted when disabled.
Now queries json_extract(data, '$.status') from documents correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tive

Menu item was in baseMenuItems unconditionally. Now only appears via
the data-driven sidebar (menuMiddleware) when plugin is active.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plugins can now self-register a custom settings tab via definePlugin's
settingsTabContent field: loadData(db) fetches server-side data, render()
produces the HTML embedded in the Settings tab of /admin/plugins/:id.

Menu plugin uses this to embed the menu editor inline, replacing the
redirect to /admin/menu.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reconcileMenuFromPlugins now queries inactive plugin IDs from the
documents table and excludes them from the active set, so deactivateStalePluginRows
hides their menu items. reconcile also runs immediately on activate/deactivate
so the change takes effect without waiting for next boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- form.submit() doesn't fire submit event so CSRF auto-injection in
  catalyst layout never ran; switch to requestSubmit() which fires it
- renderMenuSettingsContent runs in v2 layout (no CSRF script) so add
  inline CSRF injection script scoped to that embedded content
- fix visible checkbox value from '1' to 'true' to match route check

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r list

Add fetchPluginStatuses() to query plugin status from documents table.
Plugin-sourced menu items now show an 'enabled' or 'disabled' badge
alongside the source badge in both the standalone /admin/menu page
and the embedded settings tab.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace IN-clause query (which required exact pluginId→slug match)
with a query that fetches all inactive plugin slugs, then checks
membership. Avoids binding issues and ID mismatch edge cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Was filtering WHERE status='inactive' which missed plugins whose status
field is NULL. Now fetches all plugin docs and reads status directly,
matching PluginService.getAllPlugins() logic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…erage

- fetchPluginStatuses: default missing plugins to inactive (not active)
- admin-menu-list template: remove /edit suffix from edit link (404 fix)
- app.ts: register menuMiddleware before admin routes (Hono ordering fix)
- menu-plugin/index.ts: remove duplicate menuMiddleware registration
- menu.ts: replace all marker pairs via regex (fixes mobile sidebar)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds /admin/plugins link (sortOrder 50) before Menu (now 60) so the
Plugins page is always present in the DB-driven sidebar by default.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML forms can't send DELETE method; the template POSTs to /:id/delete
but only DELETE /:id existed. Adds the matching POST route.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Matches the icon used in the hardcoded sidebar fallback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds onclick to <tr> that navigates to /admin/menu/:id, skipping
clicks on interactive elements (buttons, links, inputs, forms).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fresh install has no plugin document in DB. Previously treated as active
(showing DB-driven sidebar on first boot). Now defaults to inactive so
the hardcoded sidebar shows until user explicitly activates the plugin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DB-driven sidebar now filters plugin-sourced items by active status,
matching the hardcoded sidebar's behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Revert middleware plugin-status filter; visible flag drives sidebar
- Replace fetchInactivePluginIds with fetchActivePluginIds; only
  explicitly active plugins get visible=true on first insert
- deactivateStalePluginRows now uses activeIds (not byPluginId keys)
  so inactive plugins are hidden even if still in the registry
- Also sync visible column alongside data->$.visible on deactivate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTMX hx-put was silently failing (redirect + hx-target="body" race).
Switch edit form to plain POST /admin/menu/:id/update. Extract shared
handler so PUT /:id (API) and POST /:id/update (HTML form) share logic.
Also fix visible checkbox: unchecked = absent from FormData = false.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
parent is an organizational choice, not content — admins should always
be able to move items under a different parent. Remove parent from
lockedFields in defaults, reconcile, and the updateItem guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Form always submits all fields; locked ones (url for system/plugin items)
were hitting the updateItem guard. Now skip locked fields when building
changes — parent stays always-editable as before.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Conflicts:
- wrangler.toml: kept branch DB config
- admin-plugin-settings.template.ts: merged hasUserSettings/defaultTab
  from main with settingsTabContent support from branch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant