Ga naar hoofdinhoud

CnAppNav

Manifest-driven app navigation. Renders the manifest's menu[] array as NcAppNavigation + NcAppNavigationItem. Sorts by order; filters by permission; supports one level of nested children[].

Items split into three groups by section:

  • section: "main" (default) — top of the navigation, scrollable.
  • section: "footer"regular entries rendered flat in NcAppNavigation's #footer slot, outside the scrollable list and directly above the settings foldout, so they stay visible regardless of menu length. For always-visible, non-settings links: Documentation, Features & Roadmap, About.
  • section: "settings" — rendered INSIDE an NcAppNavigationSettings foldout (the NC-native gear-icon button that slides a panel open). A "Personal settings" entry is auto-prepended at the top of the foldout (opens the host's NcAppSettingsDialog via cnOpenUserSettings); opt out with nav.includePersonalSettings: false. The foldout mounts whenever there are settings items or personal settings is enabled — so every app shows a Settings gear with at least Personal settings; it's only fully suppressed when there are no settings items and nav.includePersonalSettings: false.

Primary action

An optional primary action renders above the main list as an NcAppNavigationNew button — for a "new" button or an active-context switcher (e.g. OpenRegister's active-organisation button). Two ways to provide it:

  • #primary-action slot — full control over dynamic content and click handling. Use this when the button reflects live state (a store-driven label) or needs custom navigation. The slot wins when both are present.
  • Page-scoped pages[].primaryAction — declarative, active-page scoped. The same shape as nav.primaryAction plus an optional free-form payload. Used for the common case where each index page wants its own + New X button. Resolution order: page-scoped wins over nav.primaryAction whenever the current route matches a page that declares one.
  • nav.primaryAction manifest field — declarative app-wide default ({ id?, label, icon?, route?, href?, payload? }). On click it emits primary-action (and back-compat primary-action-click), then navigates: href opens in a new tab, route pushes the named vue-router route.

Nothing renders when neither is provided (backwards compatible). The primaryAction icon defaults to MDI Plus when icon is omitted (matches the NcAppNavigationNew default).

Top-level manifest config for the navigation:

FieldTypeDefaultDescription
nav.includePersonalSettingsBooleantrueAuto-prepend the "Personal settings" entry in the foldout. Set false for apps with no per-user settings dialog.
nav.settingsLabelString'Settings'Override the foldout gear-button label.
nav.primaryActionObjectApp-wide default primary-action button above the main list: { id?, label, icon?, route?, href?, payload? }. Overridden by pages[].primaryAction for the active route, and overridden by the #primary-action slot.

Page-scoped primaryAction

Each pages[] entry MAY declare its own primaryAction block (same shape as nav.primaryAction). When the current route matches a page that declares one, the page-scoped block wins over nav.primaryAction. This is the common case — an index page wants its own + New X button:

{
"pages": [
{
"id": "decisions",
"route": "/decisions",
"type": "index",
"title": "Decisions",
"primaryAction": {
"id": "create-decision",
"label": "+ New decision",
"icon": "Plus",
"payload": { "presetSchema": "decision" }
}
}
]
}

The host listens once at CnAppRoot (events bubble through CnPageRenderer):

<CnAppRoot @primary-action="openCreateDialog">

payload arrives unchanged so the host dispatcher can branch on it.

Slots

SlotDescription
primary-actionReplaces the manifest-driven primary-action button. Render an NcAppNavigationNew (or anything) with your own dynamic label and click handler.
searchForwarded into NcAppNavigation's #search slot. Mount your NcAppNavigationSearch here; when unset no search input renders.
item-<id>-actionsPer-item scoped slot whose content lands inside the NcAppNavigationItem's #actions slot for the entry with that id. Scope: { item }. Use it for inline NcActions menus (e.g. an item-level "Pin" button).

Events

EventPayloadDescription
primary-action{ id?, label, icon?, route?, href?, payload?, page? }Emitted when the resolved primary-action button is clicked. page is the current $route.name; payload echoes the manifest's free-form payload field. Hosts dispatch on id to wire create flows.
primary-action-clickresolved primary-action objectBack-compat alias for primary-action. New code should listen on primary-action.

manifest, translate, and permissions are read from injected values (provided by CnAppRoot) but can also be passed as props for standalone use. Props always win over inject.

Wraps: NcAppNavigation, NcAppNavigationItem

Usage

As a CnAppRoot child (typical)

<CnAppRoot :manifest="manifest" app-id="decidesk" :permissions="permissions" />
<!-- CnAppRoot mounts CnAppNav by default; no extra wiring needed. -->

Standalone (props instead of inject)

<CnAppNav
:manifest="manifest"
:translate="translate"
:permissions="permissions" />

Manifest example

{
"menu": [
{ "id": "decisions", "label": "myapp.menu.decisions", "icon": "icon-checkmark", "route": "decisions-index", "order": 10 },
{ "id": "user-settings", "label": "myapp.menu.settings", "icon": "icon-settings", "action": "user-settings", "section": "settings", "order": 100 },
{ "id": "docs", "label": "myapp.menu.docs", "href": "https://example.com/docs", "section": "settings", "order": 110 }
]
}
FieldTypeDescription
idstringUnique identifier (used as Vue key)
labelstringTranslation key — passed through translate(label)
iconstringCSS class (e.g. icon-checkmark); the active-state filter only applies to class*="icon-"
routestringVue Router named route. Resolved against manifest.pages for exact matching
hrefstringExternal link. Opens in a new tab with noopener,noreferrer. Mutually exclusive with route
action'user-settings'Built-in action. user-settings invokes the injected cnOpenUserSettings() (provided by CnAppRoot) and opens the host NcAppSettingsDialog. Both route and href are ignored when action is set
ordernumberSort order (ascending). Items without order render after items with order
section'main' | 'footer' | 'settings'Default 'main'. 'footer' = flat entry in the navigation's #footer region (outside the scroll list, always visible above the settings foldout); 'settings' = inside the gear-icon foldout
type'item' | 'caption'Default 'item'. 'caption' renders an NcAppNavigationCaption (non-interactive section divider) — route, href, action, icon, count, children, and pinned are ignored
countnumber | 'auto'Counter badge rendered in the #counter slot via NcCounterBubble. A positive integer renders as-is; 'auto' resolves the count reactively from the cnMenuCounts inject (populated by CnAppRoot from useObjectStore totals) for the entry's resolved type: "index" page ({ register, schema } in its config). A resolved 0 / null / undefined renders no badge
pinnedbooleanDefault false. Pass-through to NcAppNavigationItem's pinned prop for "main" entries inside the top list. section: "footer" entries no longer use pinned — they render in the navigation's #footer region instead
openbooleanDefault false. Initial expansion state for a parent entry with children[]. When true, the parent renders with :open="true" so children are visible on mount; users can still collapse/expand interactively
permissionstringWhen set, the item only renders if the value appears in the permissions prop / inject
childrenArray<MenuItem>One level of children supported. Each child is filtered by permission independently. Parents with visible children get :allow-collapse="true" automatically
visibleIfobjectOptional display condition block — see visibleIf conditions

visibleIf conditions

visibleIf gates a menu item behind one or more conditions. All conditions use implicit AND — every condition must pass for the item to render. Items without visibleIf are always visible (backwards-compatible).

{
"id": "view-in-mydash",
"label": "scholiq.nav.viewInMydash",
"href": "/index.php/apps/mydash#scholiq",
"visibleIf": { "appInstalled": "mydash" }
}

Checks OC.appswebroots first, then the capabilities API as fallback. Result is cached per page load.

Context-path predicates — role-based / runtime-field gating

Any key other than appInstalled is treated as a dot-separated path into manifest.runtime. The value is a predicate expression:

Predicate formExamplePasses when…
scalar"compliance-officer"value === the scalar (strict eq)
{ eq: <scalar> }{ eq: "hr-coordinator" }value === eq
{ in: [<scalar>, …] }{ in: ["hr", "compliance"] }value is in the array
{ notIn: [<scalar>, …] }{ notIn: ["guest"] }value is NOT in the array
{ gt / gte / lt / lte: <num or ISO date> }{ gt: 0 }numeric / date comparison
{ truthy: true }{ truthy: true }Boolean(value) === true
{ truthy: false }{ truthy: false }Boolean(value) === false

The backend (OpenRegister) injects manifest.runtime when serving the manifest for an authenticated request. When runtime is absent and context-path predicates are declared, the item is hidden (fail-safe — never show role-gated content to unidentified users).

Examples:

{
"id": "compliance-dashboard",
"label": "scholiq.nav.complianceDashboard",
"route": "compliance-dashboard",
"visibleIf": {
"user.primaryRole": { "in": ["compliance-officer", "hr-coordinator"] }
}
}
{
"id": "overdue-banner",
"label": "scholiq.nav.overdue",
"route": "overdue-courses",
"visibleIf": {
"user.isOverdueOnMandatoryTraining": true
}
}

Combined appInstalled + context predicate (both must pass):

{
"id": "combined",
"label": "scholiq.nav.combined",
"href": "/apps/mydash#scholiq",
"visibleIf": {
"appInstalled": "mydash",
"user.primaryRole": { "in": ["compliance-officer"] }
}
}

Props

PropTypeDefaultDescription
manifestObject | nullnullManifest object. Falls back to injected cnManifest.
translateFunction | nullnullTranslator used for labels. Falls back to injected cnTranslate (identity by default).
permissionsArray<string>[]Permissions held by the current user. Empty means all items render regardless of their permission field.

Behaviour

  • Active state — an item is active when $route.name === item.route. External (href) items never appear active.
  • Exact matching — when the resolved page's route === '/', exact is set on the underlying router-link. Without this, the root item would look permanently active for nested routes.
  • External linkshref items return null for :to, intercept the click, call preventDefault(), then open the URL via window.open(..., '_blank', 'noopener,noreferrer').
  • User settings action — items with action: "user-settings" return null for :to, intercept the click, and invoke the injected cnOpenUserSettings(). CnAppRoot provides this inject and toggles its hosted NcAppSettingsDialog. When CnAppNav is mounted standalone (no CnAppRoot ancestor), the inject defaults to a no-op so the click silently does nothing.
  • Group headers toggle on title click — an item with no route / href / action but with visible children[] is a pure group header: its anchor would be a dead # link, so clicking the title toggles the children open/closed — the same effect as the collapse chevron. CnAppNav tracks this expand/collapse state locally (seeded from the manifest's item.open, kept in sync with chevron clicks via update:open), so both click targets always agree.
  • Active icon colouricon-* background-image classes have a hardcoded dark fill, so the component injects filter: brightness(0) invert(1) to whiten them when active. <template #icon> MDI components inherit currentColor and don't need this.

Dynamic per-tenant menu entries

The menu CnAppNav renders is whatever useAppManifest ultimately resolves to — including menu[] arrays supplied by the backend /api/manifest endpoint. Apps that need per-tenant menu fan-out (e.g. one entry per catalogue or organisation) populate the resolved list in their backend; CnAppNav renders whatever the merged manifest contains. See the Dynamic per-tenant menu entries section for the contract.