Skip to main content
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 PetStore as the running example.

TutorialApp developmentManifestOpenRegisternextcloud-vueTutorial seriesAdvanced
19 min read

This is Part 5 of the nine-part app-building tutorial series. Parts 1–4 got you a working app: scaffold, schemas + manifest, schema-driven Calendar integration, and a custom Care 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 PetStore, 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.

This part deepens the manifest surface. Part 6: Integrate widens the app to other systems (cross-register reads, OpenConnector sources, webhooks). Both build directly on Part 4. Take them in either order, or pick whichever your next app needs first.

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": "petstore", "schema": "pet", "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, and explicit config.<key> still wins if you set both, so you can override a single flag without rewriting the toggle block.

{"id": "pets-admin-readout",
 "route": "/admin/pets",
 "type": "index",
 "title": "petstore.admin.pets",
 "permission": "admin",
 "config": {
   "register": "petstore", "schema": "pet",
   "columns": ["name","category","status","price","tag"],
   "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": "petstore", "schema": "pet", "readOnly": true}

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

PetStore's pet dialog has a free-text care-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-careNotes 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": "pets-create",
 "route": "/pets/new",
 "type": "form",
 "title": "petstore.pets.createTitle",
 "config": {
   "register": "petstore", "schema": "pet",
   "submitEndpoint": "/index.php/apps/openregister/api/objects/petstore/pet",
   "submitMethod": "POST",
   "mode": "create",
   "fieldWidgets": [
     {"id": "careNotes", "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 (/orders/: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": "pet-orders",
 "route": "/pets/:petId/orders",
 "type": "index",
 "title": "petstore.pets.orders",
 "config": {
   "register": "petstore", "schema": "order",
   "filter": {"pet": "@route.petId"},
   "columns": ["customer","placedAt","status","total"]
 }}

When the user visits /pets/abc-123/orders, the renderer resolves "@route.petId""abc-123" and CnIndexPage receives filter: {pet: "abc-123"}. The listing only shows orders on that pet. 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 order confirmation. The customer clicks a link in an email, lands on a page that confirms their order, sees a small form to optionally add a delivery note, no Nextcloud login required. The page is reachable by a one-shot token: anyone with the token sees it, no one without.

{"id": "order-confirm",
 "route": "/public/orders/:token",
 "type": "detail",
 "title": "petstore.orders.confirmTitle",
 "config": {
   "register": "petstore", "schema": "order",
   "mode": "public",
   "objectId": "@route.token",
   "sidebar": false,
   "fieldWidgets": [
     {"id": "customerNote", "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 care guides, knowledge-base articles, internal policies, anything that's "an article rendered from a stored markdown blob".

{"id": "care-guides",
 "route": "/care/guides/:id",
 "type": "wiki",
 "title": "petstore.care.guideTitle",
 "config": {
   "register": "petstore", "schema": "careGuide",
   "contentField": "body",
   "titleField": "title",
   "idParam": "id",
   "sidebarSchema": "careGuide",
   "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": "petstore.search.title",
 "config": {
   "scopes": [
     {"register": "petstore", "schema": "pet"},
     {"register": "petstore", "schema": "order"},
     {"register": "petstore", "schema": "careGuide"}
   ],
   "facets": ["category","status","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": "petstore.roadmap.title",
 "config": {
   "repo": "Conduction/petstore",
   "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 map is supplier X".

{"id": "suppliers-map",
 "route": "/suppliers/map",
 "type": "map",
 "title": "petstore.suppliers.mapTitle",
 "config": {
   "center": [52.13, 5.29], "zoom": 6,
   "layers": [{"type": "tile", "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png"}],
   "markers": {"dataSource": {"register": "petstore", "schema": "pet"},
               "latField": "lat", "lngField": "lng", "popupField": "name"}
 }}

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": "petstore-settings",
  "route": "/settings",
  "type": "settings",
  "title": "petstore.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.

Dashboards in depth

5b. type:'dashboard': the manifest path vs hand-rolled Vue

Parts 2–4 mentioned type:'dashboard' in the same breath as index, detail, and custom, but never sat with it. The dashboard is the surface most apps end up needing first, and it's the one where the manifest-vs-Vue trade-off shows clearest. This section walks through both routes for the same PetStore dashboard, three KPI tiles, one orders-by-day chart, one recent-orders feed, so you can pick the right path next time without re-deriving it.

What you ship looks the same either way. What changes is how much code you write, how the data binding works, and what you give up.

The widgetDef vocabulary

A dashboard is a type:'dashboard' page whose config.widgets[] array is a list of widgetDef objects. Each widgetDef declares a kind, a data source, a visual style, and a position. The renderer (CnDashboardPage) reads the array, mounts the right widget for each kind, wires up the data source, lays them out in a grid, and re-renders on data change. v2.7.0 typed every widgetDef shape so the schema catches malformed widgets before runtime.

The three kinds you'll use most:

The full kind list also covers status-rollup, map-tile, table-tile, and component (a custom mount slot, the dashboard's own escape hatch, used the same way as type:'custom' at the page level).

The PetStore dashboard, two ways

Compare. The Vue side is what most teams reach for first. The manifest side is what v2.7.0 makes possible.

Add one page entry. Every widget is a widgetDef. No Vue file.

{
  "id": "petstore-dashboard",
  "route": "/dashboard",
  "type": "dashboard",
  "title": "petstore.dashboard.title",
  "config": {
    "widgets": [
      {
        "kind": "kpi-tile",
        "id": "pets-total",
        "label": "petstore.dashboard.petsTotal",
        "color": "var(--c-cobalt-700)",
        "dataSource": {
          "register": "petstore", "schema": "pet",
          "aggregate": "count"
        },
        "position": { "row": 1, "col": 1, "w": 1, "h": 1 }
      },
      {
        "kind": "kpi-tile",
        "id": "orders-today",
        "label": "petstore.dashboard.ordersToday",
        "color": "var(--c-orange-knvb)",
        "dataSource": {
          "register": "petstore", "schema": "order",
          "aggregate": "count",
          "filter": { "placedAt": { "gte": "@today" } }
        },
        "position": { "row": 1, "col": 2, "w": 1, "h": 1 }
      },
      {
        "kind": "kpi-tile",
        "id": "occupancy-pct",
        "label": "petstore.dashboard.occupancy",
        "format": "percentage",
        "dataSource": {
          "register": "petstore", "schema": "pet",
          "aggregate": "occupancy",
          "numerator": { "filter": { "status": "sold" } },
          "denominator": { "schema": "pet", "aggregate": "count" }
        },
        "position": { "row": 1, "col": 3, "w": 1, "h": 1 }
      },
      {
        "kind": "chart",
        "id": "orders-by-day",
        "label": "petstore.dashboard.ordersLast14Days",
        "chartType": "line",
        "dataSource": {
          "register": "petstore", "schema": "order",
          "groupBy": "placedAt", "groupByGranularity": "day",
          "range": { "days": 14 }
        },
        "position": { "row": 2, "col": 1, "w": 2, "h": 2 }
      },
      {
        "kind": "activity-feed",
        "id": "recent-orders",
        "label": "petstore.dashboard.recentOrders",
        "dataSource": {
          "register": "petstore", "schema": "order",
          "orderBy": "created", "direction": "desc",
          "limit": 10
        },
        "rowConfig": {
          "titleField": "petName",
          "subtitleField": "customer",
          "timestampField": "created"
        },
        "position": { "row": 2, "col": 3, "w": 1, "h": 2 }
      }
    ]
  }
}

Five widgets, one page entry, no .vue file. The renderer:

  • Reads each dataSource, runs the OR query, caches the result
  • Mounts the correct Cn* widget for each kind
  • Lays them out via the position grid
  • Re-renders any widget whose source data changes (push from OR's notification stream, or a polling tick if you set config.refreshInterval)
  • Wires up the title (i18n key) + the colour token + the format

What you can override per-widget: color, format (number, percentage, currency, bytes), icon, link (make the tile clickable to a deeper route), and tone (the brand-status pill on a status-rollup).

What to give up to choose manifest

The manifest path doesn't fit every dashboard. Here's where it bites and what to do.

SCHEMA-DRIVEN KPIs over registers, aggregations, time-series. All native. SCHEMA-DRIVEN Activity feeds against a register or schema with simple filter/order. PARTIAL Charts: line/bar/area/doughnut/scatter native; custom annotations need kind: 'component'. PARTIAL Cross-register joins inside one widget: wire them up via OR's GraphQL endpoint and a dataSource.query block, then bind a single chart or activity-feed to the joined view. NEEDS COMPONENT Heavy interactivity inside a tile (drag-drop reorder, in-place edit, multi-step wizards): drop a kind: 'component' widget and mount a host SFC for that one tile. The other widgets stay manifest.

In other words: the manifest path covers the boring 80%. The Vue/component widget covers the spicy 20%. You don't have to pick one path for the whole dashboard. Every widget chooses independently.

Quick worked example: adding one custom widget to a manifest dashboard

You shipped the manifest dashboard above. Stakeholders ask for a sixth widget, a pet-inventory heatmap by category that's too bespoke for chart and too interactive for activity-feed. Don't rewrite the dashboard as Vue. Add one kind: 'component' widget to the existing array:

{
  "kind": "component",
  "id": "inventory-heatmap",
  "label": "petstore.dashboard.inventoryHeatmap",
  "component": "PetInventoryHeatmap",
  "props": { "category": "@route.categoryId" },
  "position": { "row": 3, "col": 1, "w": 3, "h": 2 }
}

You write PetInventoryHeatmap.vue once, register it, and let the dashboard host it as a tile. The other five widgets stay declarative. Next year when a lib CnHeatmap lands, you swap the kind: 'component' for kind: 'heatmap' and delete the SFC.

Where does the data source actually run?

All dataSource blocks resolve to OpenRegister REST/GraphQL calls at render time. The renderer batches them: five widgets, five queries, one HTTP round-trip when possible. Caching follows the standard useObjectStore rules: cached per (register, schema, filter, orderBy) tuple, invalidated on local writes, refreshed on OR's notification stream. If you set config.refreshInterval: 30000, every widget also polls every 30 seconds as a fallback for environments without push.

What happens at schema-update time?

When an order field renames, the manifest renderer picks the new field name up on next page load: no rebuild, no redeploy. The Vue path doesn't: the mounted() hook references the old field name as a string, the build still passes, and the dashboard silently shows stale or empty data until someone reads the diff. This is the single biggest reason manifest dashboards outlive their Vue cousins. They age with the schema.

When to fall back to a custom Vue page

There's a real threshold past which the manifest path costs more than it saves. Reach for type:'custom' if:

  • More than two of your widgets need kind: 'component'. At that point you're maintaining manifest plumbing for tiles that don't benefit from it.
  • The dashboard has a non-grid layout (free-positioned cards, drag-drop reordering, draggable splits) that the position grid can't express.
  • The dashboard is the app's hero screen with custom branding, transitions, and storytelling. Manifest dashboards are uniform across the fleet by design: that's a feature for admin surfaces, a constraint for hero pages.

Otherwise: start manifest, drop a component widget where you need spice, watch the rest of the fleet's lib widgets catch up to your custom over the next few releases.

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": "petstore.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": "petstore.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

Next steps