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());

code must be a frontend route — the default AsyncTreeMenu click handler runs navigate(row.code). If you seed code = 'PATIENT' (not /patient), React Router treats it as a relative path and you get /<current-page>/PATIENT, not /patient. Either put the path in code, 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/listAll

Returns 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#

  1. Seed xpm_menucode = route path (or plan Option B aliases).
  2. Grant rows for each group in xpm_acl_menu.
  3. Frontend Sidebar renders <DynamicMenu treeStore={useTreestore('/acl/menu/listAll')} />.
  4. Verify with CURL — GET /api/palmyra/acl/menu/listAll returns the user’s filtered tree.

See also#