Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.

Commit 74ea3af

Browse files
committed
feat: convert OpenBuilt's own shell to a manifest-driven Tier-4 app
OpenBuilt now eats its own dog food: instead of a hand-rolled NcContent + MainMenu.vue + vue-router shell, it declares src/manifest.json and mounts @conduction/nextcloud-vue's CnAppRoot — the same renderer it drives for the virtual apps it builds (ADR-024). - src/manifest.json: menu (Dashboard / Virtual apps / Schemas / Exports / Documentation) + pages. OpenBuilt's pages are tooling UIs, not generic register CRUD, so each is type:"custom" and resolves a view via customComponents — Dashboard, the virtual-app manager (list + detail + Editor/History/Diff tabs), the schema designer (/builder/:slug/schemas + a paramless /schemas shortcut defaulting to the hello-world seed), the export-jobs list, and the BuilderHost virtual-app host. dependencies: ["openregister"] drives CnAppRoot's dependency-check phase (the old "OpenRegister is required" empty state moves to the #dependency-missing slot). - src/customComponents.js: the registry mapping the five custom pages to src/views/*.vue. - src/App.vue: thin CnAppRoot wrapper (manifest / customComponents / pageTypes / translate / permissions) — no more bespoke chrome. - src/main.js: builds the vue-router config from manifest.pages (routesFromManifest), registerIcons() / registerTranslations(), fire-and-forget loadTranslations, mounts App.vue. Deletes src/router/index.js and src/navigation/MainMenu.vue. - SchemaDesigner.appSlug now falls back to 'hello-world' for the paramless /schemas shortcut. - tests/vitest/manifest.spec.js: structural manifest checks (menu→page, custom-page→customComponent, no dead registry entries). Stub extended with the manifest-renderer family. 84 Vitest tests pass. Verified in the dev container: /apps/openbuilt/, /applications, /schemas, /exports all render through CnPageRenderer with the manifest-driven CnAppNav. Supersedes the competing editor shells in #4/#8 (their PageDesigner / templates-gallery become customComponents in a follow-up).
1 parent 7aa2a1e commit 74ea3af

9 files changed

Lines changed: 353 additions & 175 deletions

File tree

src/App.vue

Lines changed: 118 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
<!-- SPDX-License-Identifier: EUPL-1.2 -->
2+
<!--
3+
OpenBuilt app shell. Mounts CnAppRoot with the bundled manifest and the
4+
customComponents registry; CnAppRoot handles the OpenRegister dependency
5+
check, renders CnAppNav from manifest.menu, and routes <router-view> pages
6+
through CnPageRenderer. The #dependency-missing slot keeps OpenBuilt's
7+
original "OpenRegister is required" empty state.
8+
9+
@adr ADR-024 (app manifest) — OpenBuilt is now Tier-1+ (its own shell is
10+
manifest-driven, like the virtual apps it builds).
11+
-->
212
<template>
3-
<NcContent app-name="openbuilt">
4-
<template v-if="storesReady && !hasOpenRegisters">
13+
<CnAppRoot
14+
app-id="openbuilt"
15+
:manifest="manifest"
16+
:custom-components="customComponents"
17+
:page-types="pageTypes"
18+
:translate="translateForApp"
19+
:permissions="permissions">
20+
<template #dependency-missing>
521
<NcAppContent class="open-register-missing">
622
<NcEmptyContent
723
:name="t('openbuilt', 'OpenRegister is required')"
@@ -23,64 +39,135 @@
2339
</NcEmptyContent>
2440
</NcAppContent>
2541
</template>
26-
<template v-else-if="storesReady && hasOpenRegisters">
27-
<MainMenu />
28-
<NcAppContent>
29-
<router-view />
30-
</NcAppContent>
31-
</template>
32-
<NcAppContent v-else>
33-
<div style="display: flex; justify-content: center; align-items: center; height: 100%;">
34-
<NcLoadingIcon :size="64" />
35-
</div>
36-
</NcAppContent>
37-
</NcContent>
42+
</CnAppRoot>
3843
</template>
3944

4045
<script>
41-
import { NcButton, NcContent, NcAppContent, NcEmptyContent, NcLoadingIcon } from '@nextcloud/vue'
46+
import { translate as ncT } from '@nextcloud/l10n'
4247
import { generateUrl, imagePath } from '@nextcloud/router'
48+
import { CnAppRoot } from '@conduction/nextcloud-vue'
49+
import { NcAppContent, NcButton, NcEmptyContent } from '@nextcloud/vue'
4350
import { initializeStores } from './store/store.js'
4451
import { useSettingsStore } from './store/modules/settings.js'
45-
import MainMenu from './navigation/MainMenu.vue'
4652
4753
export default {
4854
name: 'App',
55+
4956
components: {
50-
NcButton,
51-
NcContent,
57+
CnAppRoot,
5258
NcAppContent,
59+
NcButton,
5360
NcEmptyContent,
54-
NcLoadingIcon,
55-
MainMenu,
5661
},
5762
58-
data() {
59-
return {
60-
storesReady: false,
61-
}
63+
props: {
64+
/**
65+
* Bundled app manifest — passed from main.js. CnAppRoot reads
66+
* `manifest.dependencies` for the dependency-check phase and
67+
* `manifest.menu` for CnAppNav.
68+
*
69+
* @type {object}
70+
*/
71+
manifest: {
72+
type: Object,
73+
required: true,
74+
},
75+
/**
76+
* Registry of consumer-injected components used by `type: "custom"`
77+
* pages (`page.component`) and other manifest slot overrides.
78+
*
79+
* @type {object}
80+
*/
81+
customComponents: {
82+
type: Object,
83+
default: () => ({}),
84+
},
85+
/**
86+
* Page-type registry — `{ index, detail, dashboard, custom, ... }`.
87+
* Wired through to descendant CnPageRenderer instances.
88+
*
89+
* @type {?object}
90+
*/
91+
pageTypes: {
92+
type: Object,
93+
default: null,
94+
},
6295
},
6396
6497
computed: {
65-
hasOpenRegisters() {
66-
const settingsStore = useSettingsStore()
67-
return settingsStore.hasOpenRegisters
98+
/**
99+
* The current user's Nextcloud permission flags, passed to CnAppNav.
100+
*
101+
* @return {Array} Permission identifiers (empty when unavailable).
102+
*/
103+
permissions() {
104+
return window.OC?.currentUser?.permissions ?? []
68105
},
106+
107+
/**
108+
* Whether the current user is a Nextcloud admin — gates the
109+
* "Install OpenRegister" button in the dependency-missing slot.
110+
*
111+
* @return {boolean} True for admins.
112+
*/
69113
isAdmin() {
70-
const settingsStore = useSettingsStore()
71-
return settingsStore.getIsAdmin
114+
try {
115+
return useSettingsStore().getIsAdmin === true
116+
} catch (e) {
117+
return typeof window.OC?.isUserAdmin === 'function' ? window.OC.isUserAdmin() : false
118+
}
72119
},
120+
121+
/**
122+
* Path to the white-on-transparent app icon for the empty state.
123+
*
124+
* @return {string} Image path.
125+
*/
73126
appIcon() {
74127
return imagePath('openbuilt', 'app-dark.svg')
75128
},
129+
130+
/**
131+
* Deep link to OpenRegister's app-store entry.
132+
*
133+
* @return {string} Settings URL.
134+
*/
76135
appStoreUrl() {
77136
return generateUrl('/settings/apps/integration/openregister')
78137
},
79138
},
80139
81140
async created() {
82-
await initializeStores()
83-
this.storesReady = true
141+
// Pinia stores still come up so the legacy views (settings store,
142+
// schema designer, etc.) keep working. CnAppRoot doesn't depend on
143+
// them. main.js also awaits this before $mount — idempotent.
144+
try {
145+
await initializeStores()
146+
} catch (e) {
147+
// eslint-disable-next-line no-console
148+
console.error('openbuilt: initializeStores() failed', e)
149+
}
150+
},
151+
152+
methods: {
153+
/**
154+
* Translate function handed to CnAppRoot / CnAppNav / CnPageRenderer.
155+
* Closes over Nextcloud's translate so the lib never needs the app id.
156+
*
157+
* @param {string} key Translation key.
158+
* @return {string} Translated string (or the key on miss).
159+
*/
160+
translateForApp(key) {
161+
return ncT('openbuilt', key)
162+
},
84163
},
85164
}
86165
</script>
166+
167+
<style scoped>
168+
.open-register-missing {
169+
display: flex;
170+
align-items: center;
171+
justify-content: center;
172+
}
173+
</style>

src/customComponents.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: EUPL-1.2
2+
//
3+
// Custom-component registry for OpenBuilt's manifest-driven app shell.
4+
//
5+
// OpenBuilt's own pages are tooling UIs (a dashboard, the virtual-app
6+
// manager, the schema designer, the export-jobs list, the virtual-app
7+
// host) rather than generic register CRUD, so every page declares
8+
// `type: "custom"` in src/manifest.json and resolves its view here.
9+
// Keep this file SHORT — adding entries should be a deliberate decision;
10+
// removing them (by moving a page onto a built-in manifest type) is the
11+
// right direction.
12+
//
13+
// Resolution order at runtime (CnPageRenderer):
14+
// 1. Built-in page types (CnIndexPage, CnDetailPage, …)
15+
// 2. Built-in widget types (version-info, register-mapping, …)
16+
// 3. customComponents (this file) ← consumer-injected components
17+
//
18+
// See ADR-024 (app manifest) and docs/migrating-to-manifest.md in
19+
// @conduction/nextcloud-vue.
20+
21+
import DashboardView from './views/Dashboard.vue'
22+
import ApplicationsView from './views/ApplicationEditor.vue'
23+
import SchemaDesignerView from './views/SchemaDesigner.vue'
24+
import ExportJobsView from './views/ExportJobsList.vue'
25+
import BuilderHostView from './views/BuilderHost.vue'
26+
27+
export default {
28+
// Starter dashboard — sample KPIs / activity placeholders.
29+
DashboardView,
30+
// Virtual-app manager — list + detail + Editor/History/Diff tabs,
31+
// raw-JSON manifest editor, publish, RBAC permissions modal, export.
32+
ApplicationsView,
33+
// Visual schema designer for a virtual app's register
34+
// (/builder/:slug/schemas[/:schemaId] and the paramless /schemas
35+
// shortcut, which defaults to the hello-world seed app).
36+
SchemaDesignerView,
37+
// Export-jobs list — status of Phase-2 "export to real app" runs.
38+
ExportJobsView,
39+
// Virtual-app host — mounts a nested CnAppRoot rendering the virtual
40+
// app's manifest from GET /api/applications/{slug}/manifest.
41+
BuilderHostView,
42+
}

src/main.js

Lines changed: 87 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,44 @@
11
// SPDX-License-Identifier: EUPL-1.2
22
import Vue from 'vue'
3+
import VueRouter from 'vue-router'
34
import { PiniaVuePlugin } from 'pinia'
45
import { translate as t, translatePlural as n, loadTranslations } from '@nextcloud/l10n'
6+
import { generateUrl } from '@nextcloud/router'
7+
import {
8+
CnPageRenderer,
9+
defaultPageTypes,
10+
registerIcons,
11+
registerTranslations,
12+
} from '@conduction/nextcloud-vue'
513
import pinia from './pinia.js'
6-
import router from './router/index.js'
714
import App from './App.vue'
8-
import { initializeStores } from './store/store.js'
15+
import bundledManifest from './manifest.json'
16+
import customComponents from './customComponents.js'
917

10-
// Library CSS — must be explicit import (webpack tree-shakes side-effect imports from aliased packages)
18+
// Library CSS — must be an explicit import (webpack tree-shakes side-effect imports from aliased packages).
1119
import '@conduction/nextcloud-vue/css/index.css'
1220

13-
// Global (unscoped) app styles
21+
// Global (unscoped) app styles.
1422
import './assets/app.css'
1523

1624
Vue.mixin({ methods: { t, n } })
1725
Vue.use(PiniaVuePlugin)
26+
Vue.use(VueRouter)
27+
28+
// Library-side icon set + lib translations (best effort).
29+
registerIcons()
30+
try {
31+
registerTranslations()
32+
} catch (e) {
33+
// eslint-disable-next-line no-console
34+
console.warn('[openbuilt] registerTranslations failed; lib strings fall back to English source', e)
35+
}
1836

1937
// Fire-and-forget translation load. `@nextcloud/l10n`'s loadTranslations()
20-
// fetches l10n/<locale>.json (e.g. en_US.json) and the returned promise
21-
// rejects on a 404. The app ships en.json / en_US.json / nl.json; any other
22-
// locale just falls back to the English source strings. Boot MUST NOT depend
23-
// on this resolving — chaining $mount inside loadTranslations meant the whole
24-
// app silently failed to render whenever the locale bundle was missing.
38+
// fetches l10n/<locale>.json and the returned promise rejects on a 404
39+
// (and on dev installs that rewrite non-allowlisted paths to index.php it
40+
// always 404s). Boot MUST NOT depend on this resolving — strings just fall
41+
// back to their source on miss.
2542
function tryLoadTranslations() {
2643
try {
2744
const result = loadTranslations('openbuilt', () => {})
@@ -33,18 +50,68 @@ function tryLoadTranslations() {
3350
}
3451
}
3552

36-
tryLoadTranslations()
53+
// Shallow-clone CnPageRenderer because the lib's barrel exports are
54+
// non-extensible ESM module records; Vue 2's Vue.extend() attaches an
55+
// internal `_Ctor` cache to the component definition, which throws
56+
// "Cannot add property _Ctor, object is not extensible" against a frozen
57+
// source. Cloning gives vue-router an extensible options object.
58+
const RoutePageRenderer = { ...CnPageRenderer }
3759

38-
// Create the Vue instance, activate Pinia, mount, then initialise stores.
39-
const app = new Vue({
40-
pinia,
41-
router,
42-
render: h => h(App),
60+
/**
61+
* Build the vue-router config from the manifest. Each manifest page becomes
62+
* one route whose `name` IS `page.id` (the lib's manifest contract — menu
63+
* entries reference pages by id, and CnPageRenderer matches by route name).
64+
* Routes whose path declares a `:` parameter get `props: true` so route
65+
* params reach the rendered page.
66+
*
67+
* Page order in the manifest matters: more specific routes
68+
* (`/builder/:slug/schemas`, `/builder/:slug/schemas/:schemaId`) are
69+
* declared before the `/builder/:slug/:pathMatch(.*)?` wildcard so
70+
* vue-router matches them first.
71+
*
72+
* @param {object} manifest The bundled manifest (with `pages[]`).
73+
* @return {Array<object>} vue-router 3 routes config.
74+
*/
75+
function routesFromManifest(manifest) {
76+
const routes = manifest.pages.map((page) => ({
77+
name: page.id,
78+
path: page.route,
79+
component: RoutePageRenderer,
80+
props: page.route.includes(':'),
81+
}))
82+
// Catch-all redirect to the dashboard.
83+
routes.push({ path: '*', redirect: '/' })
84+
return routes
85+
}
86+
87+
const router = new VueRouter({
88+
mode: 'history',
89+
base: generateUrl('/apps/openbuilt'),
90+
routes: routesFromManifest(bundledManifest),
4391
})
4492

45-
// Mount immediately so the App renders (NC32 needs #content to be taken over).
46-
app.$mount('#content')
93+
tryLoadTranslations()
94+
95+
// Pass shallow copies of the registry maps — the lib exports
96+
// `defaultPageTypes` (and consumers' `customComponents`) as frozen module
97+
// objects in some bundle shapes, and Vue.extend() mutates component
98+
// definitions to attach `_Ctor`. Cloning yields extensible objects without
99+
// changing the values the lib resolves at render time.
100+
const pageTypesProp = { ...defaultPageTypes }
101+
const customComponentsProp = { ...customComponents }
47102

48-
// Initialise stores after mount. (App.vue also calls this in created() — it is
49-
// idempotent, so the two calls are safe; this one keeps the contract explicit.)
50-
initializeStores()
103+
// Create the Vue instance — this installs Pinia and sets it active, so the
104+
// Pinia stores are usable from App.vue's created() hook. App.vue runs
105+
// initializeStores() there (idempotent). Mount immediately so the App
106+
// renders (NC32 needs #content to be taken over).
107+
new Vue({
108+
pinia,
109+
router,
110+
render: h => h(App, {
111+
props: {
112+
manifest: bundledManifest,
113+
customComponents: customComponentsProp,
114+
pageTypes: pageTypesProp,
115+
},
116+
}),
117+
}).$mount('#content')

src/manifest.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json",
3+
"version": "1.0.0",
4+
"dependencies": ["openregister"],
5+
"menu": [
6+
{ "id": "Dashboard", "label": "Dashboard", "icon": "icon-category-dashboard", "route": "Dashboard", "order": 10 },
7+
{ "id": "VirtualApps", "label": "Virtual apps", "icon": "icon-category-app-bundles", "route": "VirtualApps", "order": 20 },
8+
{ "id": "Schemas", "label": "Schemas", "icon": "icon-category-customization", "route": "Schemas", "order": 30 },
9+
{ "id": "Exports", "label": "Exports", "icon": "icon-download", "route": "Exports", "order": 40 },
10+
{ "id": "Documentation", "label": "Documentation", "icon": "icon-info", "href": "https://github.com/ConductionNL/openbuilt", "section": "settings", "order": 90 }
11+
],
12+
"pages": [
13+
{ "id": "Dashboard", "route": "/", "type": "custom", "title": "Dashboard", "component": "DashboardView" },
14+
{ "id": "VirtualApps", "route": "/applications", "type": "custom", "title": "Virtual apps", "component": "ApplicationsView" },
15+
{ "id": "Schemas", "route": "/schemas", "type": "custom", "title": "Schemas", "component": "SchemaDesignerView" },
16+
{ "id": "SchemaDesignerList", "route": "/builder/:slug/schemas", "type": "custom", "title": "Schemas", "component": "SchemaDesignerView" },
17+
{ "id": "SchemaDesigner", "route": "/builder/:slug/schemas/:schemaId", "type": "custom", "title": "Schema", "component": "SchemaDesignerView" },
18+
{ "id": "Exports", "route": "/exports", "type": "custom", "title": "Exports", "component": "ExportJobsView" },
19+
{ "id": "BuilderHost", "route": "/builder/:slug/:pathMatch(.*)?", "type": "custom", "title": "Virtual app", "component": "BuilderHostView" }
20+
]
21+
}

0 commit comments

Comments
 (0)