Ga naar hoofdinhoud
AcademytutorialBuild a Nextcloud app on the Conduction stack — Part 5: Advanced manifest features

Build a Nextcloud app on the Conduction stack — Part 5: Advanced manifest features

Past the schema-driven CRUD basics, the v2.7.0 manifest schema gives you `actionToggles`, `fieldWidgets`, route-param sentinels, public-mode pages, and seven extra page types (form, wiki, search, roadmap, map, logs, settings). One tutorial that walks through each, using DeskDesk as the running example.

TutorialApp developmentManifestOpenRegisternextcloud-vueTutorial seriesAdvanced
10 min read

This is Part 5 of the DeskDesk tutorial. Parts 1–4 got you a working app: scaffold, schemas + manifest, schema-driven Calendar, and a custom Knowledge tab. Part 5 is the "now what" — the v2.7.0 manifest features Parts 1–4 deliberately skipped so the learning curve stayed gentle. You don't need any of them to ship DeskDesk, but the moment your app needs a public form, a wiki, an admin-only listing, or a markdown editor inside a form, they save you from another round of hand-rolled Vue.

Three principles run through every feature in this part: schema-driven (no per-page Vue), type-safe (the manifest schema validates every shape before runtime), and fleet-portable (the same JSON works in every Conduction app and gets lib upgrades for free).

1. config.actionToggles — read-only listings without nine flags

The classic shape, before v2.7.0, for an admin-only or read-only type:'index' page meant nine sibling booleans:

"config": {
  "register": "deskdesk", "schema": "desk", "columns": [...],
  "showAdd": false, "showEdit": false, "showCopy": false, "showDelete": false,
  "showMassImport": false, "showMassCopy": false, "showMassDelete": false,
  "selectable": false, "showFormDialog": false
}

config.actionToggles collapses all nine into one object. The renderer flattens it back to the underlying props at dispatch time — explicit config.<key> still wins if you set both, so you can override a single flag without rewriting the toggle block.

{"id": "desks-admin-readout",
 "route": "/admin/desks",
 "type": "index",
 "title": "deskdesk.admin.desks",
 "permission": "admin",
 "config": {
   "register": "deskdesk", "schema": "desk",
   "columns": ["label","floor","zone","equipment","capacity"],
   "actionToggles": {
     "showAdd": false, "showEdit": false, "showCopy": false,
     "showDelete": false, "showMassImport": false, "showMassCopy": false,
     "showMassDelete": false, "selectable": false, "showFormDialog": false
   }
 }}

There's a shorthand for the all-off case: config.readOnly: true expands to the same nine flags. Use it for genuinely read-only views — audit logs, archived snapshots, anything you want the user to read but not touch.

"config": {"register": "deskdesk", "schema": "desk", "readOnly": true}

2. config.fieldWidgets — lib widgets inside a form, no Vue file

DeskDesk's booking dialog has a free-text notes field. The schema-generated input gives you a <textarea> — fine, but not great. You want a markdown editor with a preview pane, syntax highlighting, and the standard toolbar. Pre-v2.7.0 that meant writing a custom Vue component, registering it, and overriding the field with a #field-notes slot.

Schema 2.7.0 typed the fieldWidgets[] slot on type:'form' and type:'detail' pages: each entry mounts a lib Cn* component for a single field by id.

{"id": "bookings-create",
 "route": "/bookings/new",
 "type": "form",
 "title": "deskdesk.bookings.createTitle",
 "config": {
   "register": "deskdesk", "schema": "booking",
   "submitEndpoint": "/index.php/apps/openregister/api/objects/deskdesk/booking",
   "submitMethod": "POST",
   "mode": "create",
   "fieldWidgets": [
     {"id": "notes", "component": "CnMarkdownEditor"}
   ]
 }}

A few rules:

  • The component value must match the lib Cn* pattern (^Cn[A-Z]\w+$). Host-app SFCs go on type:'custom' pages, not in fieldWidgets[].
  • id matches a schema property name. The widget replaces that field's auto-generated input; everything else in the form stays schema-driven.
  • props is optional — a serialisable object passed to the lib component at mount time (e.g. {"id": "preview", "component": "CnCodeViewer", "props": {"language": "yaml"}}).
  • Multiple fieldWidgets[] entries are allowed. Use them for whichever fields need richer editing; the rest of the form keeps its schema-driven inputs.

3. Route-param sentinels — bind URL params to config

The manifest can declare a route with : placeholders (/bookings/:id) and the params reach the dispatched component automatically. v2.7.0 also lets the manifest use those params inside config, by writing @route.<paramName> as a string sentinel that the renderer resolves at dispatch time:

{"id": "desk-bookings",
 "route": "/desks/:deskId/bookings",
 "type": "index",
 "title": "deskdesk.desks.bookings",
 "config": {
   "register": "deskdesk", "schema": "booking",
   "filter": {"desk": "@route.deskId"},
   "columns": ["bookedBy","start","end","status"]
 }}

When the user visits /desks/abc-123/bookings, the renderer resolves "@route.deskId""abc-123" and CnIndexPage receives filter: {desk: "abc-123"} — the listing only shows bookings on that desk. No Vue, no mounted() hook, no watch on $route.params.

Unresolved sentinels become null (with a one-shot console.warn for the page id) — they don't crash the page, they just produce no filter. Useful during incremental migrations where the route hasn't been declared yet.

4. config.mode: 'public' — token-scoped unauthenticated pages

Public booking confirmation. The user clicks a link in an email, lands on a page that confirms their booking, sees a small form to optionally add a note, no Nextcloud login required. The page is reachable by a one-shot token — anyone with the token sees it, no one without.

{"id": "booking-confirm",
 "route": "/public/bookings/:token",
 "type": "detail",
 "title": "deskdesk.bookings.confirmTitle",
 "config": {
   "register": "deskdesk", "schema": "booking",
   "mode": "public",
   "objectId": "@route.token",
   "sidebar": false,
   "fieldWidgets": [
     {"id": "guestNote", "component": "CnMarkdownEditor"}
   ]
 }}

What you get:

  • mode: "public" signals to the renderer and the backing API that this page is unauthenticated. The OR endpoint that serves it must accept the token query — that's wired in your appinfo/routes.php and a small public-mode handler in lib/Controller/.
  • objectId: "@route.token" uses the route sentinel to feed the token into the page's object lookup. The token is the object's external identifier in this flow.
  • sidebar: false explicitly turns off the audit/files/notes sidebar — public viewers shouldn't see the internal trail.
  • fieldWidgets[] works the same as on authenticated forms.

The same shape works for type:'form' (a public submission form) — the form's submitEndpoint resolves a token sentinel too.

5. Other page types — wiki, search, roadmap, map, logs, settings

Parts 2–4 used index, detail, dashboard, and custom. v2.7.0's 13-type enum gives you several more typed surfaces that drop in by manifest alone.

type: 'wiki'

For markdown-article surfaces fed from an OR register. Useful for zone guidelines, knowledge-base articles, internal policies — anything that's "an article rendered from a stored markdown blob".

{"id": "zone-guides",
 "route": "/zones/guides/:id",
 "type": "wiki",
 "title": "deskdesk.zones.guideTitle",
 "config": {
   "register": "deskdesk", "schema": "zoneGuide",
   "contentField": "body",
   "titleField": "title",
   "idParam": "id",
   "sidebarSchema": "zoneGuide",
   "treeField": "children"
 }}

The contentField is the markdown property on the article record. sidebarSchema (optional) turns on an in-page tree sidebar fed from the same register — perfect when the wiki has a hierarchy.

A faceted cross-schema search page. One config block declares which registers + schemas to query and which fields are facets. CnSearchPage does the rest.

{"id": "global-search",
 "route": "/search",
 "type": "search",
 "title": "deskdesk.search.title",
 "config": {
   "scopes": [
     {"register": "deskdesk", "schema": "desk"},
     {"register": "deskdesk", "schema": "booking"},
     {"register": "deskdesk", "schema": "zoneGuide"}
   ],
   "facets": ["floor","zone","schema"]
 }}

type: 'roadmap'

CnFeaturesAndRoadmapPage reads a manifest-declared roadmap and pulls live status from GitHub issues. Drops in for a Features & Roadmap menu entry in any app.

{"id": "features-roadmap",
 "route": "/roadmap",
 "type": "roadmap",
 "title": "deskdesk.roadmap.title",
 "config": {
   "repo": "ConductionNL/deskdesk",
   "milestones": ["1.0", "1.1", "Future"]
 }}

type: 'map'

CnMapPage mounts a Leaflet map with marker layers fed from a register, a static GeoJSON, or a tile source. Useful for asset locations, geo-tagged objects, or just "where on this floor plan is desk X".

{"id": "floor-map",
 "route": "/floors/:floorId/map",
 "type": "map",
 "title": "deskdesk.floors.mapTitle",
 "config": {
   "center": [52.13, 5.29], "zoom": 18,
   "layers": [{"type": "tile", "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"}],
   "markers": {"dataSource": {"register": "deskdesk", "schema": "desk"},
               "latField": "lat", "lngField": "lng", "popupField": "label"}
 }}

type: 'logs' and type: 'settings'

logs renders an audit/event log viewer (often points at an OR-managed auditEntry schema or an external source). settings mounts CnSettingsPage with a sections-or-tabs config that ships its own form rendering — the entire app's admin surface can be one type:'settings' page.

"settings": {
  "id": "deskdesk-settings",
  "route": "/settings",
  "type": "settings",
  "title": "deskdesk.settings.title",
  "config": {
    "sections": [
      {"id": "register-mapping", "label": "Register mapping",
       "widgets": [{"type": "register-mapping"}]},
      {"id": "version", "label": "Version",
       "widgets": [{"type": "version-info"}]}
    ]
  }
}

CnSettingsPage has its own widget vocabulary (version-info, register-mapping, component for a custom mount) that's thinner than the dashboard widgetDef. The reference page in the nc-vue docs has the full list.

6. The _note rule on type:'custom', softened

When you mount a host-app SFC via type:'custom', the v2.7.0 schema asks you to document why the custom was necessary in a _note field. That's there to fight scope creep — every custom is a place the manifest path didn't fit, and writing the reason down forces an honest answer.

{"id": "pipeline-board",
 "route": "/pipeline",
 "type": "custom",
 "title": "deskdesk.pipeline.title",
 "component": "PipelineBoard",
 "_note": "Bespoke kanban board with drag-drop column reorder + per-card status transitions. No lib analogue."}

2.7.0 softens the rule for one case: if component matches the lib Cn* pattern (^Cn[A-Z]\w+$), _note is optional because the component name already documents the choice. So:

{"id": "federation-status",
 "route": "/directory",
 "type": "custom",
 "title": "deskdesk.directory.title",
 "component": "CnFederationStatus"}

The rule still bites on host-app SFCs (no Cn* prefix), which is where it earns its keep.

7. Choosing between fieldWidgets, custom pages, and registered components

When you have a one-off UI need, you have three options. Picking the right one saves you a refactor later.

NeedUseWhy
A single rich field inside an otherwise schema-driven formconfig.fieldWidgets[] with a lib Cn* componentSmallest scope. Form stays declarative.
A whole page that doesn't fit any typed typetype:'custom' with component: 'YourHostSfc' + _noteHonest escape hatch. The rest of the manifest stays clean.
A reusable surface (a widget or tab) that several pages wantRegister an integration with OCA.OpenRegister.integrations.register({...}) and use useRegistry: trueCross-app reusable. Other Conduction apps can pick up your registration.

The bigger your type:'custom' count climbs, the more it's worth asking whether the underlying gap should be a lib feature instead. If you find yourself writing similar custom pages in two apps, file an issue on nextcloud-vue — that's how features like CnWikiPage and the typed actionToggles shape got promoted from "everyone's writing the same custom" to "first-class lib surface".

Where to go from here

Volgende stappen