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.
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.idis the vue-router route name. CnPageRenderer matches$route.name === page.idto pick the right page.page.typeis closed.index | detail | dashboard | custom. Usecustomwith acomponentregistry entry for one-off pages.page.configis forwarded as props.register,schema,columns,filters,defaultSortmap 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 + filterstype: "detail"→ CnDetailPage with the schema's propertiestype: "dashboard"→ CnDashboardPage with the layouttype: "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 itsrc/views/items/ItemList.vue,src/views/items/ItemDetail.vue— replaced by CnIndexPage + CnDetailPagesrc/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.
