Migrating to the JSON manifest
@conduction/nextcloud-vue ships a JSON-driven manifest renderer. Apps declare their routes, navigation, page content, and widget configuration in a single src/manifest.json. The library turns it into a working Nextcloud app shell.
You don't have to adopt the whole stack. Pick a tier that fits your app's current state — each tier is self-contained, and you can move up later without breaking anything.
| Tier | Add | What changes |
|---|---|---|
| 1 | useAppManifest | Just a validated, reactive manifest. No layout / routing changes. |
| 2 | + CnPageRenderer | Reuse the type-dispatch logic. App still owns its router config and root layout. |
| 3 | + CnAppNav (or a custom menu) | Manifest-driven navigation. App still owns its root shell. |
| 4 | + CnAppRoot | Full shell. Loading + dependency-check + menu + router-view, all orchestrated from the manifest. |
Tier 1 — useAppManifest only
You get a reactive, validated manifest with the future backend-override merge wired in. Everything else stays in your app.
import { useAppManifest } from '@conduction/nextcloud-vue'
import bundledManifest from './manifest.json'
export default {
setup() {
const { manifest, isLoading, validationErrors } = useAppManifest('myapp', bundledManifest)
return { manifest, isLoading, validationErrors }
},
}
manifest.value is the bundled value immediately, then deep-merged with any 200 from /index.php/apps/{appId}/api/manifest. 404 / network errors fall back silently. Schema validation failures keep the bundled value and surface in validationErrors.
Use options.endpoint and options.fetcher to override the URL or inject a mock for tests.
Tier 2 — + CnPageRenderer
Add the type dispatcher. Map your vue-router config to mount CnPageRenderer at every route — the renderer reads the manifest and dispatches to CnIndexPage / CnDetailPage / CnDashboardPage / a registry component based on page.type.
import { CnPageRenderer, useAppManifest } from '@conduction/nextcloud-vue'
const { manifest } = useAppManifest('myapp', bundledManifest)
const router = new VueRouter({
routes: bundledManifest.pages.map((p) => ({
name: p.id, // page.id IS the vue-router route name
path: p.route, // page.route is the path pattern
component: CnPageRenderer,
props: { manifest: manifest.value, customComponents: { SettingsPage } },
})),
})
CnPageRenderer accepts manifest, customComponents, and translate as props (each falls back to inject from a CnAppRoot ancestor when absent), so you can use it standalone.
Tier 3 — + CnAppNav (or your own menu)
Add manifest-driven navigation. Pass manifest and translate as props (or rely on inject if you wrap CnPageRenderer + your own provide).
<template>
<NcContent app-name="myapp">
<CnAppNav
:manifest="manifest"
:translate="translate"
:permissions="permissions" />
<router-view />
</NcContent>
</template>
<script>
import { CnAppNav, useAppManifest } from '@conduction/nextcloud-vue'
import { translate as ncT } from '@nextcloud/l10n'
import bundledManifest from './manifest.json'
export default {
components: { CnAppNav },
setup() {
const { manifest } = useAppManifest('myapp', bundledManifest)
return { manifest, translate: (key) => ncT('myapp', key) }
},
computed: {
permissions() { return window.OC?.currentUser?.permissions ?? [] },
},
}
</script>
Custom menu instead? Skip CnAppNav entirely. Either keep your existing menu component, or use CnAppRoot (tier 4) and override the #menu slot — see below.
Dynamic per-tenant menu entries
Apps whose top-level navigation depends on runtime data (catalogues, organisations, registers) populate the menu[] array from their backend /api/manifest endpoint. The bundled manifest declares a static placeholder; the backend resolves per-tenant data and returns the fully-populated list; useAppManifest's deep-merge replaces the bundled menu[] with the resolved one (arrays are replaced, not concatenated).
For example, an app like opencatalogi that previously rendered one nav entry per catalogue with v-for="catalogus in catalogs" keeps a single placeholder in src/manifest.json and lets the backend ship the resolved list:
// src/manifest.json (bundled)
{ "menu": [{ "id": "catalogs", "label": "menu.catalogs", "route": "catalogs-index" }] }
// /index.php/apps/opencatalogi/api/manifest (backend response)
{
"menu": [{
"id": "catalogs", "label": "menu.catalogs", "route": "catalogs-index",
"children": [
{ "id": "catalog-tax", "label": "menu.catalog.tax", "route": "catalog-detail" },
{ "id": "catalog-housing", "label": "menu.catalog.housing", "route": "catalog-detail" }
]
}]
}
The full contract — required fields, schema-conformance, i18n key requirement, fallback behaviour — lives in the useAppManifest reference docs. The lib never directly queries a register or schema; ADR-022 keeps the data layer behind the app's backend.
Tier 4 — + CnAppRoot
Full shell: phase orchestration (loading → dependency-check → shell), provide/inject for cnManifest / cnCustomComponents / cnTranslate, default loading and dependency-missing screens.
import Vue from 'vue'
import VueRouter from 'vue-router'
import { translate, translatePlural } from '@nextcloud/l10n'
import { CnAppRoot, CnPageRenderer, useAppManifest } from '@conduction/nextcloud-vue'
import bundledManifest from './manifest.json'
import SettingsPage from './views/SettingsPage.vue'
Vue.use(VueRouter)
Vue.mixin({ methods: { t: translate, n: translatePlural } })
const router = new VueRouter({
routes: bundledManifest.pages.map((p) => ({
name: p.id,
path: p.route,
component: CnPageRenderer,
})),
})
new Vue({
router,
render: (h) => {
const { manifest, isLoading } = useAppManifest('myapp', bundledManifest)
return h(CnAppRoot, {
props: {
manifest: manifest.value,
appId: 'myapp',
isLoading: isLoading.value,
customComponents: { SettingsPage },
translate: (key) => translate('myapp', key),
permissions: window.OC?.currentUser?.permissions ?? [],
},
})
},
}).$mount('#content')
Keeping a custom menu in Tier 4
Override the #menu slot:
<CnAppRoot :manifest="manifest" app-id="myapp" :translate="t">
<template #menu>
<MyCustomMenu />
</template>
</CnAppRoot>
CnAppRoot also exposes #loading, #dependency-missing, #header-actions, #sidebar, and #footer — each independently overridable.
App-availability guard
CnAppRoot ships with an always-on guard that checks the Nextcloud capabilities API on mount and renders an empty-state when a required app is missing. By default the guard checks for OpenRegister — every fleet app stores its data there, so the convention is encoded in the library rather than duplicated per app.
<!-- Default: guard is on, requires OpenRegister automatically -->
<CnAppRoot :manifest="manifest" app-id="myapp" :translate="t" />
<!-- Multi-app future-proofing: require both OR and openconnector -->
<CnAppRoot
:manifest="manifest"
app-id="docudesk"
:translate="t"
:requires-apps="['openregister', 'openconnector']" />
<!-- Opt out: docs site, styleguide, or any utility app that doesn't need OR -->
<CnAppRoot :manifest="manifest" app-id="docs" :translate="t" :requires-apps="[]" />
When the guard fires, the default surface is an <NcEmptyContent> with the OpenRegister database icon, an i18n title and description, and a primary action linking to the Nextcloud integration page (/index.php/settings/apps/integration/openregister). The translation keys used:
app-availability.title— fallback "OpenRegister is required"app-availability.description— fallback "This app stores its data in OpenRegister. Install or enable OpenRegister from the Nextcloud app store to continue."app-availability.action— fallback "Open app store"
Apps that want a fully custom empty-state replace it via the #or-missing scoped slot, which receives { missingApps }:
<CnAppRoot :manifest="manifest" app-id="myapp" :translate="t">
<template #or-missing="{ missingApps }">
<MyCustomMissingState :missing-apps="missingApps" />
</template>
</CnAppRoot>
While the capabilities check is in flight, CnAppRoot renders a centered <NcLoadingIcon :size="32" /> so slow connections don't flash the renderer briefly before the empty state replaces it.
If getCapabilities() rejects (admin-restricted, offline, CORS), the guard falls through to the renderer and a console.warn is logged — the data layer surfaces the actual problem if OpenRegister is genuinely missing. The guard never blocks the app on a transient capabilities-API failure.
This guard is independent of manifest.dependencies, which continues to power the per-app dependency declarations in your manifest. The two coexist: requiresApps is the prop-level "this app cannot function without these capabilities" check, while manifest.dependencies covers app-level dependency declarations resolved through useAppStatus.
What goes in manifest.json
See examples/manifest-demo/manifest.json for a full reference manifest exercising all four page types, nested menu items, permission gating, and a dependency declaration.
Key fields:
version— semver of the manifest content. Bump when meaningful changes land.dependencies— Nextcloud app ids that must be installed and enabled. CnAppRoot's dependency-check phase blocks the shell when any are missing.menu[]— top-level nav entries.id,label(i18n key), optionalicon,route(vue-router route name = page.id),order,permission, one level ofchildren[].pages[]— page definitions.id(the vue-router route name),route(path pattern),type(index | detail | dashboard | logs | settings | chat | files | form | custom),title(i18n key),config(type-specific),component(whentype: "custom"), optionalheaderComponent/actionsComponentslot overrides.
The closed type enum is the main defense against DSL creep. Anything bespoke goes in a type: "custom" page that resolves a component name from the registry you pass to CnAppRoot.
Per-tenant config slugs — the @resolve: sentinel
Most apps have one or two pages[].config.register / config.schema values that vary per tenant — theme_register, listing_schema, voorzieningen_register, etc. — typically configured by the admin via IAppConfig. Hardcoding the slug in manifest.json defeats per-tenant configurability; reading the slug at component-mount time defeats the manifest's static-validation property.
The canonical solution is the @resolve:<key> sentinel: a load-time-resolved string that the manifest renderer substitutes via IAppConfig before validation runs.
{
"pages": [
{
"id": "voorzieningen-index",
"route": "/voorzieningen",
"type": "index",
"title": "softwarecatalog.voorzieningen.title",
"config": {
"register": "@resolve:voorzieningen_register",
"schema": "voorziening"
}
}
]
}
When useAppManifest('softwarecatalog', bundled) runs, pages[0].config.register is replaced with the value of IAppConfig::getValue('softwarecatalog', 'voorzieningen_register') — typically "voorzieningen" after the admin has run the install wizard. Softwarecatalog's 12-page manifest is the canonical reference consumer: every register slug is a sentinel so the same bundle ships to every tenant.
Rules of thumb:
- Use
@resolve:whenever apages[].config.*value would otherwise be a per-tenant string. Most oftenconfig.register,config.schema, occasionallyconfig.sourcefortype: "logs". - DO NOT use
@resolve:outsidepages[].config. The validator rejects sentinels inpages[].id,pages[].route,pages[].component,menu[].route,version,dependencies[], etc. Those are router invariants or registry keys; a dynamic value would break vue-router or the customComponents registry. - Surface
unresolvedSentinelsto your admin UI. When a tenant has not yet set the IAppConfig key, the sentinel resolves tonull. The composable returns the unresolved key list so you can render a "n settings unconfigured — visit Admin → My App" banner.
See resolveManifestSentinels for the resolution source chain (initial-state → runtime fetch → null) and useAppManifest for the integrated four-phase load flow.
Type-selection guide
When a consumer faces a new page, the choice tree is:
- Is it primarily a list of objects from a known register/schema? →
index. - Is it the editor for a single object? →
detail. - Is it a dashboard of widgets? →
dashboard. - Is it a read-only audit-trail / activity-log view? →
logs. - Is it admin / system config? →
settings. - Is it a conversation thread? →
chat. - Is it a file browser? →
files. - Is it an end-user runtime form (single submit, declarable fields)? →
form. - None of the above →
custom+ a registry component.
The criterion separating built-in from custom is: does the page have a declarative data shape? Built-ins do; customs don't. Reach for a built-in whenever your page's data shape fits one — the manifest stays declarative, and the App Builder admin UI can reason about the page automatically. Pick custom only when none of the seven shapes fit.
Migrating from type: "custom" to a built-in
Every app's first manifest pass ends up with more type: "custom" pages than the team would like. Most settings / admin pages, audit trails, file browsers, and embedded chat threads should move to a built-in once the manifest schema supports them. Here's how each migration looks:
custom → logs
Before — bespoke audit-trail page in the consumer:
{ "id": "audit", "type": "custom", "component": "AuditPage", "config": {} }
After — manifest-only:
{
"id": "audit",
"type": "logs",
"title": "myapp.audit.title",
"config": {
"register": "audit-trail-immutable",
"schema": "audit-event"
}
}
Drop the AuditPage.vue component from the registry. The default CnLogsPage renders the same five-column timeline. If the consumer's existing page had bespoke filtering, expose it via a custom actionsComponent instead.
custom → settings
Before — every app's views/SettingsPage.vue:
{ "id": "settings", "type": "custom", "component": "SettingsPage" }
After — declared sections:
{
"id": "settings",
"type": "settings",
"title": "myapp.settings.title",
"config": {
"sections": [
{
"title": "myapp.settings.general",
"fields": [
{ "key": "feature_x", "type": "boolean", "label": "myapp.settings.feature_x" }
]
}
],
"saveEndpoint": "/index.php/apps/myapp/api/settings"
}
}
Migrate field-by-field. For complex inputs (JSON editors, color pickers), keep type: "settings" and fill the #field-<key> slot with the bespoke input — you don't have to fall back to custom just because one field is non-trivial.
Rich settings sections
The bare fields[] shape works for flat IAppConfig keys, but most app settings pages mix in richer widgets (a version-info card, a register/schema mapper, a bespoke configuration panel). pages[].config.sections[] accepts two more body kinds alongside fields[] — component and widgets[]. A section MUST declare exactly one of the three.
{
"id": "settings",
"type": "settings",
"title": "myapp.settings.title",
"config": {
"saveEndpoint": "/index.php/apps/myapp/api/settings",
"sections": [
{
"title": "myapp.settings.section.version",
"widgets": [
{
"type": "version-info",
"props": { "appName": "MyApp", "appVersion": "0.1.0", "showUpdateButton": true }
}
]
},
{
"title": "myapp.settings.section.registers",
"widgets": [
{
"type": "register-mapping",
"props": {
"groups": [
{ "name": "Core", "types": [{ "slug": "thing", "label": "Thing" }] }
],
"showReimportButton": true
}
}
]
},
{
"title": "myapp.settings.section.advanced",
"component": "MyAdvancedPanel",
"props": { "foo": "bar" }
},
{
"title": "myapp.settings.section.flags",
"fields": [
{ "key": "feature_x_enabled", "type": "boolean", "label": "myapp.settings.feature_x" }
]
}
]
}
}
Built-in widget types (resolved before the customComponents registry):
widget.type | Component |
|---|---|
version-info | CnVersionInfoCard |
register-mapping | CnRegisterMapping |
Anything else falls back to the consumer's customComponents registry (the same registry type: "custom" pages use).
Wiring widget events
Built-in widgets emit events (CnVersionInfoCard emits @update; CnRegisterMapping emits @save, @reimport, @update:configuration). The manifest can't carry inline JS, so CnSettingsPage re-emits every widget event as @widget-event on itself with payload { widgetType, widgetIndex, sectionIndex, name, args }. Wire one handler at the CnAppRoot mount point and dispatch by widgetType / name:
<CnAppRoot
:manifest="manifest"
:customComponents="customComponents"
@widget-event="onWidgetEvent" />
methods: {
onWidgetEvent({ widgetType, name, args }) {
if (widgetType === 'register-mapping' && name === 'save') {
this.settingsStore.saveSettings(args[0])
}
if (widgetType === 'register-mapping' && name === 'reimport') {
this.reimportRegister()
}
},
}
Decision tree — which body kind?
- Several flat IAppConfig keys? →
fields: [...]. - One whole-section pre-built library widget (version, register-mapping)? →
widgets: [{ type }]. - Several whole-section widgets stacked? →
widgets: [...]with multiple entries. - One bespoke component the library doesn't know about? →
component: <registry-name>+props(whole-section body) ORwidgets: [{ type: "component", componentName: <registry-name>, props }](one of several widgets in a section). - Mostly flat fields with one bespoke input? →
fields: [...]plus a#field-<key>slot override.
Multi-tab admin pages (tabs[] orchestration)
When a settings page has more than ~4 logical groups (think softwarecatalog's General, Catalogue, Sync, Connections, Mappings, Notifications, Branding, Advanced), a flat sections[] stack becomes a long scroll. Use tabs[] to group sections under tab buttons:
{
"id": "Settings",
"type": "settings",
"title": "myapp.settings.title",
"config": {
"saveEndpoint": "/index.php/apps/myapp/api/settings",
"tabs": [
{ "id": "general", "label": "myapp.settings.tab.general", "sections": [ /* same shape as the flat case */ ] },
{ "id": "about", "label": "myapp.settings.tab.about", "sections": [
{ "title": "myapp.settings.section.about", "widgets": [
{ "type": "version-info", "props": { "appName": "MyApp", "appVersion": "0.1.0" } }
] }
] },
{ "id": "advanced", "label": "myapp.settings.tab.advanced", "sections": [ /* … */ ] }
]
}
}
Rules:
sections[]andtabs[]are XOR — a page declares one or the other. The validator rejects manifests with both at the page-configlevel.- Each tab MUST have non-empty
idandlabel, plus a non-emptysections: [...]. Tab IDs MUST be unique within the page. - The first tab is active by default. Override via the
initialTabprop onCnSettingsPage(typically wired throughCnPageRenderer.pageTypeProps). - The page emits
@tab-changewith{ tabId, tabIndex }when the user clicks a different tab — wire it to your preference store or URL hash if you want the active tab to survive a reload.
Custom component widgets ({ type: "component", componentName })
When a settings section needs to host a bespoke consumer Vue component as one of several widgets in the section, use the explicit component discriminator with componentName:
{
"title": "myapp.settings.section.workflow",
"widgets": [
{
"type": "component",
"componentName": "WorkflowEditor",
"props": { "schemaSlug": "workflow" }
}
]
}
componentName resolves against the customComponents registry passed to CnAppRoot. The legacy widget.type === <registry-name> fallback path is kept for back-compat with manifest-settings-rich-sections consumers, but is JSDoc-deprecated — { type: "component", componentName } is the recommended way going forward because it makes the manifest reader aware that the widget body is NOT a built-in.
custom → chat
Before — a Talk-embed page:
{ "id": "chat", "type": "custom", "component": "TalkEmbed", "config": { "url": "..." } }
After:
{
"id": "chat",
"type": "chat",
"title": "myapp.chat.title",
"config": { "conversationSource": "/index.php/apps/spreed/embed/abc123" }
}
Native thread renderers (no iframe) still need type: "chat" plus a #conversation slot fill — that stays inside the manifest convention while letting you bring your own UI.
custom → files
Before — an in-app file browser:
{ "id": "uploads", "type": "custom", "component": "UploadsPage" }
After:
{
"id": "uploads",
"type": "files",
"title": "myapp.uploads.title",
"config": {
"folder": "/myapp/uploads",
"allowedTypes": ["application/pdf"]
}
}
The default listing is read-only. If you need upload / rename / delete, fill the #files-view slot with your existing file-picker — the slot scope ({ folder, allowedTypes, files, loading, error, refresh }) gives you everything the manifest declared without re-reading it.
custom → form (runtime form rendering)
type: "form" mounts CnFormPage and renders a flat fields[] array plus a submit button. Use it for end-user runtime forms — public surveys, "request a quote" pages, contact forms — where the entire route is "render this list of fields, send the result somewhere."
{
"id": "PublicSurvey",
"route": "/public/survey/:token",
"type": "form",
"title": "Survey",
"config": {
"fields": [
{ "key": "rating", "label": "Rating", "type": "number" },
{ "key": "comment", "label": "Comments", "type": "string", "widget": "textarea" }
],
"submitHandler": "submitPublicSurvey",
"mode": "public"
}
}
Submit dispatch picks one of two paths based on which field is set in config:
submitHandler: "<registryName>"— looks the name up in yourcustomComponentsregistry and calls it with(formData, $route, $router). Use this when the submit needs auth, CSRF tokens, or non-trivial URL building.submitEndpoint: "<url>"— the page callsaxios[method](url, formData)directly. URL:paramNamesegments resolve from$route.params(so/api/survey/:tokenworks automatically).
Stay on type: "custom" when the route is a form builder (drag-drop questions, branching logic, per-field validation panel) — the manifest's declarative shape doesn't fit a builder UI. See CnFormPage docs for the full prop reference.
When to stick with custom
Some shapes don't fit any built-in:
- Drag-and-drop kanban / pipeline editors (Pipelinq) — defer to a future
kanbantype. - Form builder / authoring UIs — drag-drop question ordering, branching logic editors, submission tables. The runtime renderer lives on
type: "form"; the builder stays bespoke. - Org-tree / org-chart views — no built-in today.
- Map views (geographic data) — no built-in today.
- Multi-step wizards — keep custom; wizards are too app-specific for a closed shape.
type: "custom" will always exist as the escape hatch. The goal of the manifest convention is to keep its share under ~30% across the fleet.
Custom-component registry
import SettingsPage from './views/SettingsPage.vue'
import DecisionsHeader from './views/DecisionsHeader.vue'
const customComponents = {
SettingsPage, // for type: "custom" pages with `"component": "SettingsPage"`
DecisionsHeader, // for `headerComponent: "DecisionsHeader"` slot overrides
}
Pass it to CnAppRoot (Tier 4) or to CnPageRenderer (Tier 2/3). The library statically imports nothing app-specific — your registry is the audit point for "what custom code does this app actually have?".
Column formatters
When a type: "index" (or type: "logs") page needs a column rendered through app-specific logic — a status-label map, "days in step", a currency/locale format, a human label for an enum-ish code — you don't need a bespoke type: "custom" table view just for that. Declare a formatter id on the column and register the function:
// manifest.json
{
"id": "automations",
"route": "/automations",
"type": "index",
"title": "myapp.automations.title",
"config": {
"register": "myapp",
"schema": "automation",
"columns": [
"name",
{ "key": "trigger", "label": "myapp.automations.trigger", "formatter": "automationTrigger" },
{ "key": "@self.updated", "label": "myapp.automations.daysIdle", "formatter": "daysSince", "align": "right" },
"isActive",
"runCount"
]
}
}
// src/formatters.js — small pure data functions, no Vue
export default {
// (value, row, property) => string | number
automationTrigger: (value) => ({
'lead.created': t('myapp', 'Lead created'),
'request.received': t('myapp', 'Request received'),
}[value] ?? value),
daysSince: (value) => value ? Math.floor((Date.now() - new Date(value)) / 86400000) : '—',
}
// main.js — pass it through CnAppRoot
import formatters from './formatters.js'
// …
render: (h) => h(App, { props: { manifest, customComponents, pageTypes, formatters } }),
<!-- App.vue — forward to CnAppRoot -->
<CnAppRoot :manifest="manifest" :custom-components="customComponents" :page-types="pageTypes" :formatters="formatters" … />
CnAppRoot provides the registry as cnFormatters; CnDataTable / CnCellRenderer resolve columns[].formatter against it (and pass the formatter the full row, so it can be a function of the whole record). A column with no formatter, or an app that passes no formatters, renders exactly as before; a formatter that throws degrades that one cell (logged) and falls back to the type-aware rendering. Like customComponents, src/formatters.js is the audit point for "what app-specific data shaping does this app do?" — keep the Vue layer abstract, push the per-row logic here.
Column widgets
When a column needs a component per cell — a status pill, a link, an inline toggle, a sparkline — declare a widget id (and optional widgetProps). The library ships one built-in id, "badge" (renders CnStatusBadge); everything else resolves against the cellWidgets registry you pass to CnAppRoot:
// manifest.json
"columns": [
"name",
{ "key": "status", "label": "myapp.status", "widget": "badge", "widgetProps": { "variant": "warning" } },
{ "key": "isActive", "label": "myapp.active", "widget": "active-toggle" },
{ "key": "trigger", "label": "myapp.trigger", "formatter": "automationTrigger", "widget": "badge" }
]
// src/cellWidgets.js — small components, registered by id
import ActiveToggle from './components/cells/ActiveToggle.vue'
export default { 'active-toggle': ActiveToggle }
// each widget receives { value, row, property, formatted, ...widgetProps }
// main.js
import cellWidgets from './cellWidgets.js'
render: (h) => h(App, { props: { manifest, customComponents, pageTypes, formatters, cellWidgets } }),
<!-- App.vue -->
<CnAppRoot … :formatters="formatters" :cell-widgets="cellWidgets" />
CnDataTable renders every column through CnCellRenderer, which resolves widget (consumer registry, then the built-in badge), then formatter, then the schema-type-aware rendering, then a plain formatValue() fallback. When both formatter and widget are set the widget receives the formatter-shaped value as formatted. A column with no widget/formatter renders exactly as before — except that, because cells now always flow through CnCellRenderer, manual-mode columns (a columns array with no schema) pick up the same niceties (boolean cell values render as the check-icon, long strings truncate at 100 chars with a hover title); pass a #column-{key} scoped slot if you need the raw text.
actions[].route(declarative navigation row-actions) is already available — sethandler:"navigate"+routeon an action (schema 1.3.0).CnIndexPagealso self-fetches in the manifest path (so atype:"index"page renders its object collection without a wrapper) and accepts aconfig.filterroute-param-interpolated base filter — see Self-fetch index pages below.
Aggregate columns
A column can render a count of related objects instead of a property of the row — give it an aggregate block:
// manifest.json — a type:"index" page's config.columns[]
{ "key": "submitCount", "label": "Submissions",
"aggregate": { "schema": "intakeSubmission", "op": "count",
"where": { "intakeForm": "@self.id" } } },
{ "key": "agentCount", "label": "Agents", "align": "right",
"aggregate": { "register": "pipelinq", "schema": "agentProfile", "op": "count",
"where": { "queue": "@self.id" } } }
CnDataTable reads aggregate off each column: for every visible row it issues one
GET /apps/openregister/api/objects/{register}/{schema}?{where…}&_limit=0 (reading
data.total), batched with Promise.all. String values in where of the form
"@self.<path>" are replaced per-row with getCellValue(row, path) (so
{ "intakeForm": "@self.id" } filters the related collection on intakeForm == row.id);
everything else is a literal. register defaults to the page's config.register
(CnIndexPage fills it in before handing the columns to CnDataTable — so most
manifests can omit it). The cell shows … while the count is loading and — if the
request fails (logged); a failed cell never blanks the page, and a stale batch is
discarded when the rows change. op is "count" for now (sum/min/max/avg,
each needing a field, are a planned follow-up — the column-config shape is forward-compatible).
aggregatecolumns light up on a manifesttype:"index"page becauseCnIndexPageself-fetches itsregister+schemacollection (see below) — and also work on any consumer that passesrows+columns(withaggregate) toCnDataTabledirectly, or viaCnTableWidget's self-fetch mode.
Self-fetch index pages
A manifest type:"index" page dispatches to CnIndexPage, and CnPageRenderer spreads pages[].config onto it (register, schema, columns, sidebar, actions, filter) plus $route.params — but never an objects prop. So when register and schema are both set and no objects prop is passed, CnIndexPage self-fetches: it derives objectType = '${register}-${schema}', registers it in the object store, and drives the whole list (collection fetch, _search/_order/_page/_limit, facet filters, schema load, sidebar wiring, the @search/@sort/@page-changed/@filter-change/@refresh handlers) through useListView against the store an ancestor CnAppRoot provides. No wrapper component, no App.vue wiring:
{
"id": "decisions",
"route": "/decisions",
"type": "index",
"title": "myapp.decisions.title",
"config": {
"register": "decidesk",
"schema": "decision",
"sidebar": { "enabled": true }
}
}
config.schema is a slug here — CnIndexPage's schema prop accepts Object | String, and the slug's resolved schema object drives column generation. (Passing an objects prop — every current consumer — keeps the existing consumer-managed behaviour: no store touched, props win.)
Scoping a list to a parent — config.filter
config.filter (an object, spread onto CnIndexPage as the filter prop) is merged into every fetch as a fixed filter — the user's facet selection for the same key can't override it. String values of the form "@route.<name>" or ":<name>" resolve against $route.params; everything else is literal. The filter re-resolves when $route.params change, so a list nested under a parent route is a fully declarative page:
{
"id": "form-submissions",
"route": "/forms/:id/submissions",
"type": "index",
"title": "myapp.submissions.title",
"config": {
"register": "pipelinq",
"schema": "intakeSubmission",
"filter": { "intakeForm": "@route.id", "archived": false }
}
}
config.filter needs no schema change — pages[].config is additionalProperties: true. In consumer-managed mode (objects prop supplied) filter has no effect.
Under the hood:
useListView('<objectType>', { …, fixedFilters })gained anopts.fixedFilters— a plain object or a getter returning one — spread into the fetch params after the user'sactiveFiltersso the fixed entries always win.CnIndexPagepasses a getter that re-interpolatesfilterfrom$route.paramson every fetch.
Quick-filter tabs (config.quickFilters)
When a list page wants clickable tab toggles that change the active filter (e.g. Open / Closed / All, or Mine / Team / Everyone), declare config.quickFilters:
{
"id": "Tasks",
"type": "index",
"config": {
"register": "app",
"schema": "task",
"quickFilters": [
{ "label": "Open", "filter": { "status": "open" }, "default": true },
{ "label": "Closed", "filter": { "status": "closed" } },
{ "label": "Mine", "filter": { "assignee": "@route.userId" } }
]
}
}
CnIndexPage renders CnQuickFilterBar above the table (pill-shaped buttons; active one filled with --color-primary-element). The active tab's filter is merged into every fetch after config.filter (so the tab wins on a colliding key) and before the user's facet activeFilters (which still narrow within the active tab). String values resolve @route.<name> / :<name> from $route.params the same way config.filter does. The first entry with default:true (else index 0) is active on mount; switching tabs re-fetches at page 1 and emits @quick-filter-change for observers. Omit quickFilters for the current no-tab behaviour.
Read-only shorthand (config.readOnly)
When an index page is purely read-only (a log view, a generated report, an audit trail), spelling out selectable:false, showAdd:false, showFormDialog:false, showEditAction:false, showCopyAction:false, showDeleteAction:false, showMassImport:false, showMassCopy:false, showMassDelete:false is tedious. Declare config.readOnly: true and CnPageRenderer expands it to those nine defaults — merged under the explicit config.* props so any explicit override still wins:
{
"id": "AutomationHistory",
"type": "index",
"config": {
"register": "app",
"schema": "automationLog",
"filter": { "automation": "@route.id" },
"readOnly": true
}
}
If you want a few of the read-only defaults overridden, mix them in:
"config": {
"readOnly": true,
"selectable": true // ← keep selection on; everything else still falsy
}
Built-in cell formatters / widgets
CnAppRoot ships a few built-in cnFormatters / cnCellWidgets so common manifest cell-rendering needs work without per-app boilerplate. Consumer-registered entries with the same id win on collision (override path).
Formatters
| id | What |
|---|---|
date | Intl.DateTimeFormat dateStyle:"medium" — locale-aware date, no time. |
datetime | Date + timeStyle:"short". |
relative-time | Intl.RelativeTimeFormat — "3 days ago" / "in 2 hours". |
"columns": [
{ "key": "createdAt", "label": "Created", "formatter": "date" },
{ "key": "lastSeenAt", "label": "Last seen", "formatter": "relative-time" }
]
All three are safe against null / "" / non-parseable values — they return an empty string or the original value rather than throwing.
Widgets
| id | What |
|---|---|
badge | Renders the value as a CnStatusBadge pill. widgetProps.variant picks the colour (default "default"). |
link | Renders the value as a navigable link. Resolution order: widgetProps.route (a manifest page id) → <router-link> to {name: route, params: {id: row[rowKey]}}; else widgetProps.href → <a target="_blank" rel="noopener"> (with {key} placeholders substituted from the row); else plain text + a once-per-session console.warn (silence with widgetProps.fallback: "silent"). For non-id route params, pass widgetProps.params: { routeParamName: "rowFieldName" }. |
"columns": [
{ "key": "title", "label": "Title", "widget": "link", "widgetProps": { "route": "ContactDetail" } },
{ "key": "status", "label": "Status", "widget": "badge", "widgetProps": { "variant": "warning" } }
]
widget is checked AFTER any consumer registry entry, so an app can override "link" / "badge" by registering same-named components on CnAppRoot's :cell-widgets.
pages[].permission (schema-only)
A pages[] entry may carry an optional permission: <string> — a permission identifier for consumer-side access control:
{ "id": "AdminPage", "route": "/admin", "type": "index", "title": "Admin", "permission": "admin", "config": {…} }
The library does not currently enforce permission (it ignores the field at render time). Consumers that want enforcement filter the manifest themselves before passing it to CnAppRoot:
const filtered = {
...manifest,
pages: manifest.pages.filter((p) => !p.permission || perms.includes(p.permission)),
menu: manifest.menu.filter((m) => {
const page = manifest.pages.find((p) => p.id === m.route)
return !page?.permission || perms.includes(page.permission)
}),
}
Adding permission to a page is safe — validate-manifest.js accepts it.
Sidebar (manifest-driven)
Both index and detail pages can drive their sidebar entirely from manifest.json — no consumer-side wiring required for the common shapes.
Index sidebar
Set pages[].config.sidebar on a type: "index" page to auto-mount CnIndexSidebar inside CnIndexPage:
{
"id": "decisions",
"route": "/decisions",
"type": "index",
"title": "myapp.decisions.title",
"config": {
"register": "decisions",
"schema": "decision",
"sidebar": {
"enabled": true,
"showMetadata": true,
"search": { "searchPlaceholder": "myapp.decisions.search" }
}
}
}
Shape: { enabled, show?, columnGroups?, facets?, showMetadata?, search? }. show (default true) is a separate visibility gate — set false to hide the embedded sidebar without removing the rest of the config. Forward search/filter/columns events at the page level (@search, @columns-change, @filter-change on CnIndexPage).
Detail sidebar tabs
Set pages[].config.sidebarProps.tabs on a type: "detail" page to drive CnObjectSidebar's tabs from the manifest. Each tab declares either a widgets list (type: 'data' | 'metadata' | <registry-name>) or a component registry name:
{
"id": "decision",
"route": "/decisions/:id",
"type": "detail",
"title": "myapp.decisions.detail",
"config": {
"register": "decisions",
"schema": "decision",
"sidebar": true,
"sidebarProps": {
"tabs": [
{ "id": "overview", "label": "myapp.tabs.overview",
"widgets": [{ "type": "data" }, { "type": "metadata" }] },
{ "id": "related", "label": "myapp.tabs.related",
"component": "MyRelatedTab" }
]
}
}
}
The tabs array flows through the existing objectSidebarState provide/inject channel that CnDetailPage already populates with objectId / register / schema / hiddenTabs. The host app's mounted CnObjectSidebar reads it and replaces the hard-coded built-in tab set (Files / Notes / Tags / Tasks / Audit Trail) with the manifest's array. When unset, the built-in tabs render as today.
Custom tab component names resolve against the same customComponents registry that powers type: "custom" pages and headerComponent / actionsComponent overrides, so one registry covers every consumer-injected component.
Detail sidebar Object form (preferred)
config.sidebar on a detail page now ALSO accepts an Object that mirrors the index sidebar shape. The Object form folds register / schema / hiddenTabs / title / subtitle / tabs into a single config block — no more split between sidebar (Boolean) and sidebarProps (Object):
{
"id": "decision",
"route": "/decisions/:id",
"type": "detail",
"title": "myapp.decisions.detail",
"config": {
"register": "decisions",
"schema": "decision",
"sidebar": {
"show": true,
"register": "decisions",
"schema": "decision",
"hiddenTabs": ["notes"],
"tabs": [
{ "id": "overview", "label": "myapp.tabs.overview",
"widgets": [{ "type": "data" }, { "type": "metadata" }] },
{ "id": "related", "label": "myapp.tabs.related",
"component": "MyRelatedTab" }
]
}
}
}
The Boolean form ("sidebar": true) and the legacy sidebarProps field continue to work for backwards compatibility. Migrate at your own pace — the library logs a one-shot console.warn per CnDetailPage instance the first time it observes the Boolean form.
Hiding the sidebar per page (pages[].sidebar.show)
Every page entry MAY declare a top-level sidebar field (sibling of config) with one sub-property — show: boolean. When false, the host App's #sidebar slot stops rendering for that page, and CnPageRenderer applies the CSS hook class cn-page-renderer--no-sidebar on its wrapper. Works on every page type, including type: "custom":
{
"id": "wide-canvas",
"route": "/wide",
"type": "custom",
"title": "myapp.wide",
"component": "WideCanvasPage",
"sidebar": { "show": false }
}
This avoids the older "drop into type: 'custom' and re-implement the shell just to hide a sidebar" workaround. Apps shelling via CnAppRoot get this for free — CnAppRoot already gates <slot name="sidebar" /> on the cnPageSidebarVisible inject. Apps that mount their own sidebar without CnAppRoot need to inject cnPageSidebarVisible themselves and gate accordingly (one inject + a v-if).
Per-route sidebar swap (pages[].sidebarComponent)
Some routes need a completely different sidebar component than the rest of the app — typically a search filter pane, a chat reading pane, a map's layer panel, etc. In Vue Router this is the named-view pattern (<router-view name="sidebar">), where each route declares co-mounted components and the host renders sibling <router-view> and <router-view name="sidebar"> elements.
The manifest expresses the same idea declaratively via pages[].sidebarComponent — a string referencing a key in the customComponents registry. When set, CnPageRenderer resolves the name and CnAppRoot mounts the resolved component as the default content of its #sidebar slot for that page only:
{
"id": "search",
"route": "/search",
"type": "custom",
"title": "menu.search",
"component": "SearchPage",
"sidebarComponent": "SearchSideBar"
}
// customComponents passed to CnAppRoot
import SearchPage from './views/search/SearchIndex.vue'
import SearchSideBar from './sidebars/search/SearchSideBar.vue'
createApp({ /* ... */ }).provide('customComponents', { SearchPage, SearchSideBar })
Every other page renders the host's default sidebar (whatever the consumer wired into the #sidebar slot — typically a sibling CnIndexSidebar / CnObjectSidebar pair). The consumer's slot override (when supplied) wins over the resolved component via Vue's slot mechanic — apps can adopt incrementally.
Composes deterministically with sidebar.show:
sidebar.show: falseALWAYS wins. The slot does not render at all and the resolved component is suppressed.- A page declaring both
sidebar.show: falseANDsidebarComponenttriggers a one-lineconsole.warnso manifest authors notice the dead config.
When to use which sidebar field
| Goal | Field |
|---|---|
| Hide the sidebar entirely on this page | pages[].sidebar.show: false |
Per-tab content on the built-in CnObjectSidebar (Files / Notes / Tags / Tasks / Audit Trail + custom tabs) | pages[].config.sidebar.tabs[] (detail) or pages[].config.sidebarProps.tabs[] (legacy) |
| Per-page full-sidebar swap (this page renders a completely different sidebar component) | pages[].sidebarComponent |
| Index-page columns / facets / search panel | pages[].config.sidebar (Object form, enabled: true) |
Migrating from <router-view name="sidebar">
Before:
// router/index.js — named-view binding
{ path: '/search', name: 'Search', components: { default: SearchPage, sidebar: SearchSideBar } }
<!-- App.vue — sibling render slot -->
<router-view />
<router-view name="sidebar" />
After:
// router/index.js — back to a single component per route
{ path: '/search', name: 'Search', component: SearchPage }
// manifest.json — sidebar declared at the page level
{ "id": "Search", "route": "/search", "type": "custom",
"component": "SearchPage", "sidebarComponent": "SearchSideBar" }
<!-- App.vue — CnAppRoot's #sidebar slot handles the rendering -->
<CnAppRoot ...>
<template #default><router-view /></template>
</CnAppRoot>
Apps that already wire sibling sidebars (CnObjectSidebar + CnIndexSidebar next to <router-view> in App.vue) can adopt this incrementally — the slot override wins over the resolved component until the consumer is ready to remove the override.
i18n
The manifest stores translation keys only — decidesk.menu.decisions, never inline strings. Pass a translate function ((key) => string) to CnAppRoot / CnAppNav / CnPageRenderer. Typically a closure over @nextcloud/l10n's translate(appId, key). The library never imports t() from a specific app.
This makes mechanical i18n key checking possible in CI — every translatable string in the manifest is a static field of a known shape (see ConductionNL/hydra#194).
Validating manifests at build time
import { validateManifest } from '@conduction/nextcloud-vue'
const result = validateManifest(myManifest)
if (!result.valid) {
console.error('manifest invalid:', result.errors)
process.exit(1)
}
The same validator runs at runtime inside useAppManifest against any backend-merged result; failures fall back to the bundled manifest with a console.warn.
Schema-validated config shapes
As of schema version 1.2.0 (the manifest-config-refs change), the manifest schema's seven $defs are referenced from the recurring pages[].config sub-properties they describe. Editor autocomplete, build-time Ajv validation, and CI lint surface schema-level enforcement against the typed shapes.
$def | Purpose | Used inside |
|---|---|---|
column | Table column definition | pages[].config.columns[] (index, logs) — admits string-shorthand via oneOf |
action | Row / bulk action | pages[].config.actions[] (index) |
widgetDef | Dashboard widget definition | pages[].config.widgets[] (dashboard) |
layoutItem | Dashboard grid layout entry | pages[].config.layout[] (dashboard) |
formField | Schema-driven form field | pages[].config.sections[].fields[] (settings) |
sidebarSection | Index sidebar config group | pages[].config.sidebar.columnGroups[] (index) |
sidebarTab | Detail sidebar tab | pages[].config.sidebar.tabs[] (preferred) and pages[].config.sidebarProps.tabs[] (legacy) (detail) |
The OUTER pages[].config block keeps additionalProperties: true so per-type scalars (register, schema, source, folder, saveEndpoint, conversationSource, postUrl, allowedTypes) and consumer-app extension keys remain free-form. The detail config.sidebar is a oneOf [boolean, object] — the legacy boolean form keeps validating; the Object form's tabs[] is typed but the rest of the object stays open.
What error messages look like
The validateManifest FE helper mirrors the schema's strictness with JSON-pointer-shaped error messages:
import { validateManifest } from '@conduction/nextcloud-vue'
const result = validateManifest(myManifest)
if (!result.valid) {
console.error(result.errors)
// [
// '/pages/0/config/widgets/0/type: must be a non-empty string',
// '/pages/2/config/actions/0/label: must be a non-empty string',
// '/pages/3/config/sections/0/fields/0/type: must be one of boolean, number, string, enum, password, json',
// '/pages/4/config/layout/0/gridWidth: must be >= 1',
// ]
}
JSON Schema-aware editors (VSCode + the JSON / YAML schema extension) surface inline shape violations against the same $refs.
Legacy shorthand kept for back-compat
Existing v1.0 / v1.1 manifests with columns: ["title", "status", "deadline"] (array of strings) keep validating — the oneOf admits both the shorthand string form and the typed object form. Detail config.sidebar: true / false (boolean) likewise keeps validating.
The component-level shapes remain the source of truth at runtime; the $defs are the JSON-side contract. See docs/utilities/manifest-defs.md for one-line examples per $def plus the full custom-fallback list.