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.
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
componentvalue must match the libCn*pattern (^Cn[A-Z]\w+$). Host-app SFCs go ontype:'custom'pages, not infieldWidgets[]. idmatches a schema property name. The widget replaces that field's auto-generated input; everything else in the form stays schema-driven.propsis 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 yourappinfo/routes.phpand a small public-mode handler inlib/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: falseexplicitly 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.
type: 'search'
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.
| Need | Use | Why |
|---|---|---|
| A single rich field inside an otherwise schema-driven form | config.fieldWidgets[] with a lib Cn* component | Smallest scope. Form stays declarative. |
| A whole page that doesn't fit any typed type | type:'custom' with component: 'YourHostSfc' + _note | Honest escape hatch. The rest of the manifest stays clean. |
| A reusable surface (a widget or tab) that several pages want | Register an integration with OCA.OpenRegister.integrations.register({...}) and use useRegistry: true | Cross-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".
