Skip to main content
AcademytutorialBuild a Nextcloud app on the Conduction stack — Part 2: Schemas + manifest

Build a Nextcloud app on the Conduction stack — Part 2: Schemas + manifest

Define the desk, booking, and floor schemas in OpenRegister, declare them in a manifest.json, and watch CnAppRoot replace ~200 lines of hand-rolled Vue with three lines of config.

TutorialApp developmentOpenRegisterSchemasManifestnextcloud-vueTutorial series
11 min read

This is Part 2 of the six-part DeskDesk tutorial. Part 1 left you with an empty app shell — a chassis, no data. Part 2 fills the chassis: three schemas, a manifest, and the same five Cn* pages drive every list, every detail view, every dashboard with the schema as the single source of truth.

The shape we keep saying "this saves you code" finally has numbers behind it: ~200 lines of hand-rolled Vue collapse to three.

Step 1: Define the three schemas

Open lib/Settings/deskdesk_register.json and replace the placeholder article schema with the real three. Each schema is a JSON Schema with a couple of OpenRegister extensions: slug for the URL identifier, icon for the MDI glyph the UI uses, and x-openregister-relations for typed cross-schema links.

"floor": {
  "slug": "floor",
  "title": "Floor",
  "description": "A physical floor in a building.",
  "type": "object",
  "required": ["label"],
  "properties": {
    "label":     { "type": "string", "example": "Floor 3" },
    "building":  { "type": "string", "example": "Amsterdam HQ" },
    "planImage": { "type": "string", "format": "uri" }
  }
}

The desk schema adds the typed relation to floor:

"desk": {
  "slug": "desk",
  "title": "Desk",
  "type": "object",
  "required": ["label", "floor", "zone"],
  "properties": {
    "label": { "type": "string", "example": "3-East-12" },
    "floor": {
      "type": "string",
      "x-openregister-relations": {
        "schema": "floor",
        "cardinality": "many-to-one"
      }
    },
    "zone": {
      "type": "string",
      "enum": ["north", "south", "east", "west", "central"]
    },
    "equipment": {
      "type": "array",
      "items": { "type": "string", "enum": ["dual-monitor", "standing", "phonebooth-adjacent", "wired-network", "extra-power"] }
    },
    "capacity":      { "type": "integer", "minimum": 1, "default": 1 },
    "accessibility": { "type": "boolean", "default": false },
    "photo":         { "type": "string", "format": "uri" },
    "notes":         { "type": "string" }
  }
}

Booking is the same shape — a relation, a couple of date-times, an enum status, and an optional RRULE for recurring bookings.

The full file ships in the deskdesk repo, with five seed desks, two floors, and three bookings so the app is browsable on first install.

Step 2: Declare the register itself

OpenRegister's import handler creates schemas eagerly, but it only creates a register when the JSON declares one explicitly. Add a components.registers block above your schemas:

"components": {
  "registers": {
    "deskdesk": {
      "slug": "deskdesk",
      "title": "DeskDesk",
      "description": "Floors, desks, and bookings for the DeskDesk app.",
      "version": "0.2.0",
      "schemas": ["floor", "desk", "booking"]
    }
  },
  "schemas": { /* ... */ },
  "objects": { /* ... */ }
}

The schemas array tells OpenRegister which of the schemas in the same file belong to this register. The slug becomes the register's stable identifier.

Step 3: Trigger the import

Two ways. Either trigger it via the SettingsController (the app exposes POST /api/settings/load):

curl -X POST -b cookies.txt \
  -H "requesttoken: $REQUESTTOKEN" \
  http://localhost:8080/index.php/apps/deskdesk/api/settings/load

Or — cleaner during local development — disable + re-enable the app, which fires the <install> repair step from appinfo/info.xml:

docker exec nextcloud php occ app:disable deskdesk
docker exec nextcloud php occ app:enable deskdesk

Either way, you should see in the OpenRegister registers list:

slug: deskdesk    title: DeskDesk    application: deskdesk    schemas: floor, desk, booking

The matching path the SettingsService takes is importFromFilePath with a path resolved relative to \OC::$SERVERROOT (which is /var/www/html in a default install). The boot-time bug I hit during this tutorial — Configuration file not found: html/custom_apps/deskdesk/... — was a relative-path mistake; the fix lands in the diff.

Step 4: The manifest

src/manifest.json is the single declarative description of the app shell. Four top-level keys you care about: $schema, dependencies, menu, pages.

{
  "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest-v2.schema.json",
  "version": "2.0.0",
  "dependencies": ["openregister"],
  "menu": [
    {"id": "desks-index",    "label": "deskdesk.menu.desks",    "icon": "icon-category-organization", "route": "desks-index",    "order": 10},
    {"id": "bookings-index", "label": "deskdesk.menu.bookings", "icon": "icon-calendar",              "route": "bookings-index", "order": 20},
    {"id": "floors-index",   "label": "deskdesk.menu.floors",   "icon": "icon-category-monitoring",   "route": "floors-index",   "order": 30}
  ],
  "pages": [
    {"id": "desks-index",  "route": "/",          "type": "index",  "title": "deskdesk.desks.indexTitle",
     "config": {"register": "deskdesk", "schema": "desk",
                "columns": ["label","floor","zone","equipment","capacity","accessibility"],
                "filters": ["floor","zone","equipment","accessibility"],
                "defaultSort": {"key": "label", "order": "asc"}}},
    {"id": "desks-detail", "route": "/desks/:id", "type": "detail", "title": "deskdesk.desks.detailTitle",
     "config": {"register": "deskdesk", "schema": "desk", "sidebar": true}}
    /* bookings + floors pages follow the same pattern */
  ]
}

A few rules to note:

  • The $schema URL points at app-manifest-v2.schema.json. This is the v2 schema (currently at version 2.7.0). It's the source of truth for what manifest.json is allowed to contain — your IDE picks up autocompletion and validation from it the moment the URL is present.
  • page.id is the vue-router route name. CnPageRenderer matches $route.name === page.id to pick the right page entry from pages[].
  • page.type is a closed enum. The 13 supported types: index | detail | dashboard | logs | settings | chat | files | form | wiki | map | search | roadmap | custom. Most apps will use index, detail, dashboard, and custom; the others are there when you need them. See Part 5 for form/wiki/search/map examples.
  • page.config is forwarded as props. register, schema, columns, filters, defaultSort map to CnIndexPage props one-to-one. The renderer also forwards top-level page fields (title, widgets, actions, sidebar, description, icon) alongside config, and bridges config.schema → objectType plus :id → objectId for type:'detail' so the lib's external CnObjectSidebar activates automatically.
  • config.sidebar: true on type:'detail' activates the host's mounted CnObjectSidebar with its built-in tabs (Files, Notes, Tags, Tasks, Audit Trail) for the current object. Without it the detail page renders without a sidebar — useful for read-only views, but for DeskDesk every detail page wants the standard tabs, so we set it everywhere.

Step 5: Replace App.vue and the router

Now the satisfying part. Open src/App.vue — the 175-line shell from Part 1 — and replace it with this:

<template>
  <CnAppRoot :app-id="appId" :manifest="manifest" />
</template>

<script>
import { CnAppRoot } from '@conduction/nextcloud-vue'
import manifest from './manifest.json'

export default {
  name: 'App',
  components: { CnAppRoot },
  data: () => ({ appId: 'deskdesk', manifest }),
}
</script>

That's it. The <NcContent>, the dependency-check empty-state, the loading spinner, the menu, the sidebars, the user-settings dialog — CnAppRoot ships them all and gates them on the manifest's dependencies array.

Then src/router/index.js becomes a manifest-to-route mapping:

import Vue from 'vue'
import Router from 'vue-router'
import { generateUrl } from '@nextcloud/router'
import { CnPageRenderer } from '@conduction/nextcloud-vue'
import manifest from '../manifest.json'

Vue.use(Router)

const routes = manifest.pages.map((page) => ({
  name: page.id,
  path: page.route,
  component: CnPageRenderer,
}))

routes.push({ path: '*', redirect: '/' })

export default new Router({
  mode: 'history',
  base: generateUrl('/apps/deskdesk'),
  routes,
})

Every page in the manifest gets a route. Every route renders CnPageRenderer. CnPageRenderer reads the manifest, finds the matching page entry by route name, and dispatches by page.type. The most common dispatches:

  • type: "index"CnIndexPage with the schema's columns + filters
  • type: "detail"CnDetailPage with the schema's properties + the lib's external sidebar
  • type: "dashboard"CnDashboardPage with the layout grid
  • type: "form"CnFormPage for create/edit flows (supports public-mode token-scoped variant)
  • type: "wiki"CnWikiPage for markdown-article surfaces
  • type: "search" → faceted cross-schema search
  • type: "custom" → your own SFC registered in customComponents

Schema 2.7.0 (the current beta) introduces a few v2-only shapes worth flagging — full coverage lives in Part 5, but it's useful to know the names now:

  • config.actionToggles — typed object on type:'index' collapsing the nine show*/selectable flags into one place. actionToggles: { showAdd: true, showEdit: false, showDelete: false } reads cleaner than nine sibling booleans.
  • config.fieldWidgets[] — typed slot on type:'form' and type:'detail' for mounting a lib Cn* component as a single form field. e.g. fieldWidgets: [{id: 'body', component: 'CnMarkdownEditor'}].
  • config.mode: 'public' — on type:'form' and type:'detail', marks an unauthenticated token-scoped page. Pair with a route-param sentinel like submitEndpoint: "/index.php/apps/myapp/api/public/:token".
  • config.sidebar: true | {...} — boolean or object form. Object form ({enabled, show, register, schema, hiddenTabs, tabs}) gives full control; the boolean is the shortcut for the common case.
  • _note on type:'custom' — required when the custom mounts a host-app SFC (no lib Cn* analogue); softened in 2.7.0 so that a custom with component: 'CnSomething' no longer needs it.

You won't use most of those in Part 2 — three index pages, three detail pages, one dashboard, all schema-driven. They're listed here so when Part 5 reaches for them they're not surprises.

Step 6: Delete the old views

The whole point of Part 2 is that the framework writes the views for you. Delete:

  • src/navigation/MainMenu.vue — replaced by CnAppNav (auto-mounted by CnAppRoot)
  • src/views/Dashboard.vue — placeholder; will return in a richer form when the dashboard page wants it
  • src/views/items/ItemList.vue, src/views/items/ItemDetail.vue — replaced by CnIndexPage + CnDetailPage
  • src/views/settings/UserSettings.vue, src/views/settings/Settings.vue — settings live on the admin page (AdminRoot.vue) and the in-app dialog ships with CnAppRoot

The before-and-after on disk:

before: 12 .vue files in src/, ~600 lines of hand-rolled component code
after:   3 .vue files (App.vue, AdminRoot.vue, that's it), ~30 lines

Step 7: Build, reload, browse

npm run build
docker cp ./js nextcloud:/var/www/html/custom_apps/deskdesk/
docker exec nextcloud apache2ctl graceful

Open /apps/deskdesk/. The left rail now reads Desks · Bookings · Floors (the translation keys will look like deskdesk.menu.desks until you add the l10n/ entries — that's a follow-up). The main column shows CnIndexPage with the schema's columns. Click a row, you're on CnDetailPage for that desk. The whole CRUD flow comes for free.

Why this matters

You wrote three JSON files. The framework reads them and renders the app. There is no DesksList.vue, no DeskForm.vue, no useDesks.js. When you add a priority field to the booking schema, every place a booking appears — table column, detail row, filter sidebar, form dialog — picks up the new field automatically.

This is the schema-driven discipline talked about on nextcloud-vue.conduction.nl. Part 3 puts it to work in a way that surprises people the first time: a single JSON block on the booking schema makes every booking appear in the user's Nextcloud Calendar, no controller, no event, no listener.

Troubleshooting

What's next

Next steps