Ga naar hoofdinhoud

CnPageRenderer

JSON-driven page dispatcher. Mounted inside <router-view>, CnPageRenderer reads the manifest, finds the page definition whose id matches the current route name ($route.name === page.id), and renders the appropriate component by dispatching on page.type.

Page types are resolved via the pageTypes registry. The library ships a built-in registry (defaultPageTypesindex, detail, dashboard) and consumers extend it by passing a merged map. The custom type is special: it resolves page.component (and page.sidebarComponent / slot-override names) against a custom-component registry rather than pageTypes.

The renderer resolves custom-component names in this order (ADR-036):

  1. registry prop / cnRegistry inject — the v2 kind-tagged registry. An entry matching the name wins.
  2. customComponents prop / cnCustomComponents inject — the legacy flat { name: Component } map.

Both can co-exist while consumers migrate; pass either. The legacy map is the deprecated source per ADR-036, kept as a backward-compat fallback.

The discriminator on registry lookups depends on the resolution site:

  • Page dispatch (page.component for type:"custom" pages, page.sidebarComponent) requires kind: "page". Other-kind entries with the same name are ignored and resolution falls through to the legacy map. A type:"custom" page MAY omit page.component and instead name its body component via page.slots.main — when page.component is absent the main slot entry is promoted to the page body (resolved as kind:"page") and is not also mounted as a named slot. An explicit page.component still wins, and slots.main then behaves as an ordinary named slot.
  • Slot overrides (page.slots[*], page.headerComponent, page.actionsComponent, page.config.sections[*].component) are kind-agnostic — any registry entry with a component field resolves, so consumers can fully migrate off customComponents by parking dashboard widgets / settings sections / action menus in registry.js with semantic kinds (widget / section / actions).

Each entry in pageTypes is wrapped in defineAsyncComponent, so apps using only a subset pay no bundle cost for the others (notably the GridStack-backed dashboard).

manifest, customComponents, pageTypes, and translate are injected from CnAppRoot by default; each can also be passed as props for standalone use. Props always win over inject.

$listeners and $attrs forwarding

CnPageRenderer declares inheritAttrs: false and forwards both $listeners and $attrs to every dispatched page component (alongside the explicit :is, :key, and v-bind="resolvedProps"). This is the Vue 2 idiom for transparent listener / attribute pass-through.

Practical consequences:

  • Events emitted by the page surface to the host. A CnDashboardPage emitting @widget-action bubbles through CnPageRenderer and reaches the host App (typically CnAppRoot) without any manifest plumbing. Same for @primary-action (from CnAppNav's primary-action button) and any other custom events the dispatched page chooses to emit.
  • Attributes set on <CnPageRenderer> reach the page's $attrs — useful for instrumentation (data-host-context="…"). The wrapping .cn-page-renderer <div> does not also receive them (because inheritAttrs: false), so the attribute lands exactly once on the dispatched component.
  • Explicit prop bindings still win. When resolvedProps declares title: "Decisions" and the host passes a title attribute on <CnPageRenderer>, the explicit resolvedProps.title wins for the page's title prop; the host attribute reaches $attrs but does not override.

Hosts that previously relied on manifest-config plumbing to expose page events should now listen directly on CnAppRoot (the events bubble through).

Usage

Inside CnAppRoot via vue-router

// router.js
import { CnPageRenderer } from '@conduction/nextcloud-vue'
const routes = manifest.pages.map((page) => ({
name: page.id, // CnPageRenderer matches by $route.name === page.id
path: page.route,
component: CnPageRenderer,
}))
<!-- App.vue (under CnAppRoot's <router-view />) -->
<CnPageRenderer />

Standalone (props instead of inject)

<CnPageRenderer
:manifest="manifest"
:custom-components="customComponents"
:page-types="pageTypes" />

Manifest example

{
"pages": [
{
"id": "decisions-index",
"route": "/decisions",
"type": "index",
"title": "Decisions",
"config": { "register": "decisions", "schema": "decision", "columns": ["title", "status"] }
},
{
"id": "decisions-detail",
"route": "/decisions/:id",
"type": "detail",
"title": "Decision",
"config": { "register": "decisions", "schema": "decision" },
"headerComponent": "DecisionHeader",
"actionsComponent": "DecisionActions",
"slots": { "footer": "DecisionFooter" }
},
{
"id": "settings",
"route": "/settings",
"type": "custom",
"title": "Settings",
"component": "SettingsPage"
}
]
}

Props

PropTypeDefaultDescription
manifestObject | nullnullManifest. Falls back to injected cnManifest.
customComponentsObject | nullnullRegistry for type: "custom" pages and slot-override names. Falls back to injected cnCustomComponents (then {}).
translateFunction | nullnullFalls back to injected cnTranslate. Reserved for future use; the renderer doesn't currently call it directly.
pageTypesObject | nullnullMap of pages[].type → Vue component. Falls back to injected cnPageTypes, then to the library's defaultPageTypes.

Page resolution

PropertyBehaviour
page.idMatched against $route.name. The instance's $options.name is set to CnPageRenderer:<id> for cleaner Vue devtools / stack traces
page.typeLooked up in pageTypes. Special-case: "custom" resolves page.component in customComponents instead
page.title / page.description / page.iconForwarded as props onto the resolved component (defaults — page.config.* and $route.params.* still win on collision). Lets a manifest entry render its header from a single top-level field without duplicating into config.
page.configSpread as props onto the resolved component. Overrides any collision with the top-level title/description/icon defaults above.
page.config.createOverride (type=index)A string naming an async create handler. Resolved across both registries: a v2 cnRegistry entry with kind:'create-override' exposing the function as .handler (or .fn), a directly function-valued registry entry, or a function-valued legacy customComponents entry. The resolved function is forwarded to CnIndexPage's createOverride prop, so a create from the generic Add button runs the app's side-effecting persistence path (e.g. a contact-aware endpoint filling a required FK) instead of saving straight to OpenRegister. An unresolved name is dropped with a one-line console.warn.
page.slots{ slotName: registryName } map — each entry resolves a customComponents entry and mounts it inside the corresponding scoped slot
page.headerComponentSugar for slots.header (sugar wins when both are set)
page.actionsComponentSugar for slots.actions (sugar wins when both are set)
page.sidebar{ show?: boolean } object — sibling of config. Drives a reactive cnPageSidebarVisible provide and a CSS hook class. See Per-page sidebar visibility below.
page.sidebarComponentRegistry name (string). Resolved against customComponents and pushed onto a reactive cnPageSidebarComponent provide so CnAppRoot's #sidebar slot mounts it as default content for this page only. See Per-page sidebar component below.

When page.type (or a registered customComponents name) is missing, the renderer logs console.warn once and mounts nothing rather than crashing.

Per-page sidebar visibility

Each page entry MAY declare a top-level sidebar object (sibling of config) that gates the host App's #sidebar slot for the lifetime of that page mount. Currently sidebar exposes one field:

FieldTypeDefaultDescription
sidebar.showBooleantrueWhether the host App's #sidebar slot renders for this page. Set to false to hide the sidebar declaratively. Works on every page type — including type:"custom" where config is opaque.

When sidebar.show is false:

  1. The renderer applies CSS class cn-page-renderer--no-sidebar on its wrapper element (consumer-styled hook for layout-driven sidebars).
  2. The renderer flips a reactive cnPageSidebarVisible value from true to false and provides it under that inject key.

CnAppRoot injects cnPageSidebarVisible and gates <slot name="sidebar" /> accordingly. The default — used when no CnPageRenderer ancestor is present (e.g. apps that mount their own page components directly) — resolves to a value-true holder so the slot keeps rendering.

{
"id": "wide-canvas",
"type": "custom",
"title": "Wide canvas",
"component": "WideCanvasPage",
"sidebar": { "show": false }
}

If your app wires its own sidebar without CnAppRoot, inject cnPageSidebarVisible directly:

<script>
export default {
inject: { cnPageSidebarVisible: { default: () => ({ value: true }) } },
}
</script>
<template>
<div>
<router-view />
<CnObjectSidebar v-if="cnPageSidebarVisible.value !== false" />
</div>
</template>

Per-page sidebar component

Each page entry MAY declare a top-level sidebarComponent field (sibling of config) — a string referencing a key in the consuming app's customComponents registry. When set, CnPageRenderer resolves the name and publishes the resolved component on the cnPageSidebarComponent reactive provide channel. CnAppRoot injects the holder and renders the resolved component as the default content of its #sidebar slot.

This is the manifest-side equivalent of Vue Router's named-view sidebar pattern. The canonical use case is a route that needs a completely different sidebar component than the rest of the app — e.g. opencatalogi's Search route hosting a SearchSideBar instead of the shared CnIndexSidebar / CnObjectSidebar.

FieldTypeDefaultDescription
sidebarComponentStringunsetKey in customComponents. The resolved component renders inside CnAppRoot's #sidebar slot as default content — the consumer's #sidebar slot override (when supplied) wins via Vue's slot mechanic. Unknown names log a console.warn and the holder stays null (slot falls through to consumer content).

Composes with sidebar.show:

  • sidebar.show: false ALWAYS wins — the slot does not render at all and the resolved component is suppressed.
  • A page declaring both sidebar.show: false AND sidebarComponent triggers a one-line console.warn so manifest authors notice the dead config.
{
"id": "search",
"route": "/search",
"type": "custom",
"title": "menu.search",
"component": "SearchPage",
"sidebarComponent": "SearchSideBar"
}
// customComponents registry (passed to CnAppRoot)
import SearchSideBar from './sidebars/search/SearchSideBar.vue'
import SearchPage from './views/search/SearchIndex.vue'

{ SearchPage, SearchSideBar }

If your app wires its own sidebar without CnAppRoot, inject the holder directly:

<script>
export default {
inject: {
cnPageSidebarVisible: { default: () => ({ value: true }) },
cnPageSidebarComponent: { default: () => ({ value: null }) },
},
}
</script>
<template>
<div v-if="cnPageSidebarVisible.value !== false">
<component :is="cnPageSidebarComponent.value" v-if="cnPageSidebarComponent.value" />
<CnObjectSidebar v-else />
</div>
</template>

For per-tab content on the built-in CnObjectSidebar (Files / Notes / Tags / Tasks / Audit Trail) use pages[].config.sidebar.tabs[] — see CnObjectSidebar. sidebarComponent is for the full-sidebar swap case.

Slot-override forwarding

// page in manifest
{
"id": "decisions-detail",
"type": "detail",
"slots": { "header": "DecisionHeader", "footer": "DecisionFooter" }
}

// customComponents registry (passed to CnAppRoot or directly to CnPageRenderer)
{ DecisionHeader, DecisionFooter }

The renderer mounts the resolved registry components inside the dispatched page component's scoped slots — so the page component receives them under its standard #header / #footer names with whatever scope it provides.

Detail-page object loading

For a type:"detail" page, the renderer loads the object the page is about and publishes it to descendant widgets — so the body/sidebar widgets (data, metadata, file-manager, …) render the object with no per-widget props.

It resolves { register, schema, objectId } from the page config (register, schema, and idParam — typically a @route.* sentinel like "@route.id", falling back to the :objectId / :id route param), registers the ${register}-${schema} object type, fetches the object + schema via useObjectStore, and exposes them on the cnDetailObjectContext inject (a reactive { value } holder). CnWidgetGrid merges that context under each widget's props. The load is defensive (a Pinia-less harness or a failed fetch leaves the context null and the page still mounts) and re-runs when the register/schema/objectId triple changes.

{
"id": "PublicationDetail",
"route": "/publications/:catalogSlug/:id",
"type": "detail",
"config": { "register": "publication", "schema": "publication", "idParam": "@route.id" },
"widgets": [
{ "widgetKey": "data", "slot": "body", "gridWidth": 8, "gridHeight": 4 },
{ "widgetKey": "metadata", "slot": "sidebar", "gridWidth": 1, "gridHeight": 2 }
]
}
  • CnAppRoot — Provides manifest / customComponents / pageTypes via inject.
  • defaultPageTypes — Built-in index, detail, dashboard types.
  • validateManifest — The validator that enforces page.id uniqueness, required fields, and component for type: "custom" pages.