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-vue
9 min read

This is Part 2 of the four-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. Three top-level keys: dependencies, menu, pages.

{
  "$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json",
  "version": "1.0.0",
  "id": "deskdesk",
  "title": "DeskDesk",
  "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"}}
    /* bookings + floors pages follow the same pattern */
  ]
}

A few rules to note:

  • page.id is the vue-router route name. CnPageRenderer matches $route.name === page.id to pick the right page.
  • page.type is closed. index | detail | dashboard | custom. Use custom with a component registry entry for one-off pages.
  • page.config is forwarded as props. register, schema, columns, filters, defaultSort map to CnIndexPage props one-to-one.

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:

  • type: "index" → CnIndexPage with the schema's columns + filters
  • type: "detail" → CnDetailPage with the schema's properties
  • type: "dashboard" → CnDashboardPage with the layout
  • type: "custom" → your registered component

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