Dynamic Navigation — data-driven sidebar#
Most admin apps want a side nav that’s (a) driven by data, not hard-coded, and (b) filtered per-user by ACL grants. palmyra-dbacl-mgmt gives you the backend half (two tables + one endpoint); @palmyralabs/template-tribble gives you the frontend half (DynamicMenu). This guide stitches them together.
Architecture#
xpm_menu ─── parent/child tree of nav entries (id, name, code, display_label, path, …)
│
│ M:N via xpm_acl_menu (menu_id × group_id × mask)
│
xpm_group ── xpm_acl_user ── xpm_user
│
│ current logged-in user
▼
GET /acl/menu/listAll → rows the user's groups have mask > 0 on
│
▼
React: useTreestore + DynamicMenu (AsyncTreeMenu under the hood)1. The tables#
Both ship as JPA entities inside palmyra-dbacl-mgmt — Hibernate creates them with ddl-auto: update:
xpm_menu — the nav tree:
| Column | Purpose |
|---|---|
id |
PK |
parent |
Self-FK → xpm_menu.id. NULL = root |
name |
Internal name |
code |
Navigation target — what the client navigates to on click |
display_label |
User-visible text |
path |
Optional canonical path (not used by default renderer) |
display_order |
Sort within siblings |
active |
1 = visible, 0 = hidden |
action, icon, external_url, page_ref, … |
Optional |
xpm_acl_menu — per-group grant:
| Column | Purpose |
|---|---|
menu_id × group_id |
Composite key |
mask |
> 0 = visible to the group |
2. Seed the nav tree#
Example — four top-level sections + leaves:
INSERT IGNORE INTO xpm_menu (parent, name, code, display_label, active, display_order,
created_by, last_upd_by, created_on, last_upd_on) VALUES
(NULL, 'IMAGING', 'IMAGING', 'Imaging', 1, 10, 'seed','seed', NOW(), NOW()),
(NULL, 'WORKFLOW', 'WORKFLOW', 'Workflow', 1, 20, 'seed','seed', NOW(), NOW()),
(NULL, 'ADMIN', 'ADMIN', 'Administration', 1, 30, 'seed','seed', NOW(), NOW());
INSERT IGNORE INTO xpm_menu (parent, name, code, display_label, active, display_order,
created_by, last_upd_by, created_on, last_upd_on) VALUES
((SELECT id FROM (SELECT id FROM xpm_menu WHERE code='IMAGING') AS t),
'PATIENT', '/patient', 'Patient', 1, 1, 'seed','seed', NOW(), NOW()),
((SELECT id FROM (SELECT id FROM xpm_menu WHERE code='IMAGING') AS t),
'EXAM', '/exam', 'Exam', 1, 2, 'seed','seed', NOW(), NOW());
codemust be a frontend route — the defaultAsyncTreeMenuclick handler runsnavigate(row.code). If you seedcode = 'PATIENT'(not/patient), React Router treats it as a relative path and you get/<current-page>/PATIENT, not/patient. Either put the path incode, or register alias routes that match the class-style codes.
Grant the admin group mask=1 on every menu row:
INSERT INTO xpm_acl_menu (menu_id, group_id, mask, created_by, last_upd_by, created_on, last_upd_on)
SELECT m.id, g.id, 1, 'seed','seed', NOW(), NOW()
FROM xpm_menu m
CROSS JOIN xpm_group g
WHERE g.name = 'AppAdmin'
AND NOT EXISTS (SELECT 1 FROM xpm_acl_menu x WHERE x.menu_id = m.id AND x.group_id = g.id);3. The endpoint#
AclController.getSideMenu is auto-registered by palmyra-dbacl-mgmt:
GET {prefix}/acl/menu/listAllReturns only rows the current user’s groups have mask > 0 on, flat but with parent + GROUP_CONCAT’d children so the client can rebuild the tree:
{
"result": [
{ "id": 1, "name": "IMAGING", "code": "IMAGING", "display_label": "Imaging",
"parent": null, "children": "5,6", "display_order": 10 },
{ "id": 5, "name": "PATIENT", "code": "/patient", "display_label": "Patient",
"parent": 1, "children": null, "display_order": 1 },
…
]
}4. The frontend — DynamicMenu#
import { DynamicMenu } from '@palmyralabs/template-tribble';
import { useTreestore } from 'wire/StoreFactory';
import { ServiceEndpoint } from 'config/ServiceEndpoints';
export function Sidebar() {
const treeStore = useTreestore('/acl/menu/listAll');
return (
<nav className="app-shell__nav">
<div className="app-shell__brand">MyApp</div>
<DynamicMenu treeStore={treeStore} />
</nav>
);
}useTreestore returns a TreeQueryStore bound to the endpoint; DynamicMenu hands it to AsyncTreeMenu from @palmyralabs/rt-forms. Click handler reads metadata.code and calls navigate(...).
The store factory helper#
If your StoreFactory.ts doesn’t already have useTreestore, add it:
import { IEndPoint } from '@palmyralabs/palmyra-wire';
import AppStoreFactory from './StoreFactory';
export const useTreestore = (ep: IEndPoint, opts: Record<string, any> = {}) =>
AppStoreFactory.getTreeStore(opts, ep);5. The “code must be a route” gotcha — two options#
Option A (cleanest) — seed xpm_menu.code with the route path:
INSERT INTO xpm_menu (code, display_label, ...) VALUES
('/patient', 'Patient', ...);Option B — seed code with class-style names, then register matching aliases in React Router:
// App.tsx — alias classic codes to the friendly paths
<Route path="MrcpPatient" element={<PatientGridPage />} />
<Route path="/patient" element={<PatientGridPage />} />Option B is useful when you’re porting from a legacy system that already uses class-style menu codes.
6. Group editor — toggle which groups see which menus#
palmyra-dbacl-mgmt exposes two endpoints for the menu-editor UI:
| Endpoint | Purpose |
|---|---|
GET {prefix}/admin/acl/group/{groupId}/menuList |
Tree with each row’s mask for this group |
PUT {prefix}/admin/acl/group/{groupId} |
Persist changes from the editor |
Use them with a TreeMenuEditor component (see @palmyralabs/rt-forms-mantine / -mui) — feed it the storeFactory + endPoint + groupId, it handles the round-trip.
Checklist#
- Seed
xpm_menu—code= route path (or plan Option B aliases). - Grant rows for each group in
xpm_acl_menu. - Frontend
Sidebarrenders<DynamicMenu treeStore={useTreestore('/acl/menu/listAll')} />. - Verify with CURL —
GET /api/palmyra/acl/menu/listAllreturns the user’s filtered tree.
See also#
AsyncTreeMenu— the widget underneathDynamicMenu- ACL Management integration guide — the broader schema + endpoint context
TreeQueryStore— the wire-layer contractuseTreestorereturns