From 3d231df4d4e5fa0ebde5d5478facffe76ee61001 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 16 Apr 2026 17:16:23 +0200 Subject: [PATCH 01/25] fix: backport gitignore typo fix, deduplication, and add @nextcloud/auth dep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from planix: correct .phphunit → .phpunit typo, remove duplicate glob patterns, and add @nextcloud/auth for getCurrentUser() support. Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 7 +------ package.json | 1 + 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 9a9cc78..8a13382 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /.idea/ /*.iml *.Identifier -.phphunit.result.cache +.phpunit.result.cache /vendor/ /vendor-bin/*/vendor/ @@ -59,11 +59,6 @@ phpqa_output.log simple-solr-test.php test-solr-connection.php -# Files with unusual extensions or no extensions that could be mistakes -**/PR * -**/adds * -**/implements * - phpqa/ # Docker AI models (too large for git) diff --git a/package.json b/package.json index c79ae1e..b308756 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ ], "dependencies": { "@conduction/nextcloud-vue": "^0.1.0-beta.3", + "@nextcloud/auth": "^2.5.0", "@nextcloud/axios": "^2.5.0", "@nextcloud/dialogs": "^3.2.0", "@nextcloud/initial-state": "^2.2.0", From 6dcdd0d8aef0b95efbd9c1931c1afa06380083e8 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 16 Apr 2026 18:31:00 +0200 Subject: [PATCH 02/25] chore: replace schemas/conduction symlink with schemas directory symlink Replace the individual conduction symlink inside openspec/schemas/ with a single symlink at openspec/schemas pointing to hydra schemas directory. Co-Authored-By: Claude Opus 4.6 (1M context) --- openspec/schemas | 1 + openspec/schemas/conduction | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 120000 openspec/schemas delete mode 120000 openspec/schemas/conduction diff --git a/openspec/schemas b/openspec/schemas new file mode 120000 index 0000000..f396f65 --- /dev/null +++ b/openspec/schemas @@ -0,0 +1 @@ +../../../../../../hydra/openspec/schemas \ No newline at end of file diff --git a/openspec/schemas/conduction b/openspec/schemas/conduction deleted file mode 120000 index 80cef47..0000000 --- a/openspec/schemas/conduction +++ /dev/null @@ -1 +0,0 @@ -../../../.claude/openspec/schemas/conduction \ No newline at end of file From 2f788c3a0003aed79b87ab312d5fa80a7880b87e Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Fri, 1 May 2026 13:48:17 +0200 Subject: [PATCH 03/25] chore(sbom): remove per-app SBOM workflow + checked-in SBOM (release-asset only) (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The central Quality workflow (ConductionNL/.github#34) now publishes SBOMs exclusively as release assets — see SECURITY.md "Software Bill of Materials". This PR cleans up the per-app remnants: - delete .github/workflows/sbom.yml (the central job replaces it) - delete the checked-in sbom.cdx.json (release asset is the source of truth) - gitignore SBOM files so future generations don't accidentally land in repo Stable URL for clients: https://github.com/ConductionNL/nextcloud-app-template/releases/latest/download/sbom.cdx.json Co-authored-by: SBOM Cleanup --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 8a13382..9d24e97 100644 --- a/.gitignore +++ b/.gitignore @@ -83,3 +83,8 @@ openspec/test-site-results/**/*.jpg openspec/test-site-results/**/*.jpeg openspec/test-site-results/**/*.gif openspec/test-site-results/**/*.webp + +# SBOM is published as release asset (see SECURITY.md), not stored in repo +sbom.cdx.json +bom-php.cdx.json +bom-npm.cdx.json From e618b462a0bb76fc9f2e7f9419755ba905ffa2b0 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sun, 3 May 2026 16:40:42 +0200 Subject: [PATCH 04/25] chore: add .github/CODEOWNERS for auto-review-request (#25) Path-based codeowner mapping per the OR-abstraction-audit follow-up (2026-05-03). PRs that touch each domain auto-request review from the matching owners; first-to-approve unblocks per the org ruleset. --- .github/CODEOWNERS | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..f5f4f07 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,36 @@ +# CODEOWNERS — auto-request reviewers based on path domain. +# Last-match-wins; broad rules first, specific overrides below. +# Created 2026-05-03 from the OR-abstraction audit follow-up. + +# Default: every named codeowner reviews unmatched paths. +* @rubenvdlinde @rjzondervan @Rem-Dam @remko48 @WilcoLouwerse @bbrands02 @SudoThijn + +# Backend (PHP) — services, controllers, mappers, db, migration, etc. +lib/ @bbrands02 @rjzondervan @WilcoLouwerse +appinfo/ @bbrands02 @rjzondervan @WilcoLouwerse +**/*.php @bbrands02 @rjzondervan @WilcoLouwerse +phpcs.xml @bbrands02 @rjzondervan @WilcoLouwerse +phpmd.xml @bbrands02 @rjzondervan @WilcoLouwerse +phpstan.neon @bbrands02 @rjzondervan @WilcoLouwerse +phpstan-baseline.neon @bbrands02 @rjzondervan @WilcoLouwerse +phpmd.baseline.xml @bbrands02 @rjzondervan @WilcoLouwerse +composer.json @bbrands02 @rjzondervan @WilcoLouwerse +composer.lock @bbrands02 @rjzondervan @WilcoLouwerse + +# Frontend (Vue / TS / JS) — components, stores, pages, build config. +src/ @SudoThijn @remko48 +**/*.vue @SudoThijn @remko48 +**/*.ts @SudoThijn @remko48 +**/*.js @SudoThijn @remko48 +package.json @SudoThijn @remko48 +package-lock.json @SudoThijn @remko48 +jest.config.js @SudoThijn @remko48 +playwright.config.ts @SudoThijn @remko48 +webpack.config.js @SudoThijn @remko48 +babel.config.js @SudoThijn @remko48 + +# Specs / docs / ADRs / openspec. +openspec/ @rubenvdlinde @Rem-Dam +docs/ @rubenvdlinde @Rem-Dam +**/*.md @rubenvdlinde @Rem-Dam +README.md @rubenvdlinde @Rem-Dam From 0d95a46e7c2450e7136489e6977e2d949b40c1c5 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 5 May 2026 09:04:49 +0200 Subject: [PATCH 05/25] feat: add example dashboard widget + splitChunks bundling pattern (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the ConductionNL bundling pattern from ADR-004 (Build / bundling) into the template so apps cloned from this repo get a working dashboard widget out of the box and never trip the appName/devtool/duplicate-framework pitfalls that landed across opencatalogi/pipelinq/procest/docudesk. What is added: - webpack.config.js: optimization.splitChunks with stable-filename shared chunks for Vue + @nextcloud/vue + pinia + icons + @conduction/nextcloud-vue. Each entry-point keeps only entry-specific code; shared chunks load once. - lib/Dashboard/ExampleWidget.php: minimal IWidget. load() attaches shared chunks BEFORE the per-widget bundle (vendor → nc-vue → widget). Comments explain why and reference ADR-004. - src/exampleWidget.js: webpack entry that registers the Vue renderer via OCA.Dashboard.register. Hard-coded id matches Widget::getId() from PHP. - src/views/widgets/ExampleWidget.vue: minimal NcDashboardWidget that fetches /api/items via @nextcloud/axios with try/catch + graceful empty state. - AppInfo/Application.php: registerDashboardWidget(ExampleWidget::class). - README: 'Adding a dashboard widget' how-to listing the 5 registration points and pointing at ADR-004 for the full rationale. Apps that don't need a dashboard widget delete: - lib/Dashboard/ + src/exampleWidget.js + src/views/widgets/ - the registerDashboardWidget(...) line in Application.php - the exampleWidget entry in webpack.config.js The splitChunks block is harmless with only main + adminSettings entries (produces small shared chunks that two entries reuse) and starts paying off the moment a widget is added. --- README.md | 30 ++++++- lib/AppInfo/Application.php | 6 ++ lib/Dashboard/ExampleWidget.php | 129 ++++++++++++++++++++++++++++ src/exampleWidget.js | 31 +++++++ src/views/widgets/ExampleWidget.vue | 65 ++++++++++++++ webpack.config.js | 103 ++++++++++++++++------ 6 files changed, 337 insertions(+), 27 deletions(-) create mode 100644 lib/Dashboard/ExampleWidget.php create mode 100644 src/exampleWidget.js create mode 100644 src/views/widgets/ExampleWidget.vue diff --git a/README.md b/README.md index 3342792..822b3f3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ app-template/ ├── lib/ # PHP backend │ ├── AppInfo/Application.php │ ├── Controller/ # DashboardController, SettingsController +│ ├── Dashboard/ # Nextcloud Dashboard widget classes (ExampleWidget) │ ├── Service/SettingsService.php │ ├── Listener/DeepLinkRegistrationListener.php │ ├── Repair/InitializeSettings.php @@ -71,11 +72,13 @@ app-template/ ├── templates/ # PHP templates (SPA shells) ├── src/ # Vue 2 frontend │ ├── main.js # App entry point +│ ├── exampleWidget.js # Sample Nextcloud Dashboard widget entry-point │ ├── App.vue # Root component │ ├── navigation/MainMenu.vue # App navigation sidebar │ ├── router/ # Vue Router │ ├── store/ # Pinia stores -│ └── views/ # Route-level views + UserSettings.vue +│ ├── views/ # Route-level views + UserSettings.vue +│ └── views/widgets/ # Dashboard widget Vue components (ExampleWidget.vue) ├── openspec/ # Specifications, decisions, and roadmap │ ├── app-config.json # Canonical app config (id, goal, dependencies, CI) │ ├── config.yaml # OpenSpec CLI configuration @@ -135,6 +138,31 @@ npm run dev # Watch mode npm run build # Production build ``` +### Adding a dashboard widget + +The template ships with a working `ExampleWidget` you can copy. Each widget is +**three files plus two registration points**: + +1. `lib/Dashboard/Widget.php` — implements `OCP\Dashboard\IWidget`. The + `load()` method MUST attach the two shared chunks (`-shared-vendor`, + `-shared-nc-vue`) **before** the per-widget bundle. Order matters; see + `ExampleWidget.php` for the reference. +2. `src/Widget.js` — webpack entry-point that registers the renderer via + `OCA.Dashboard.register('', (el, { widget }) => { ... })`. The id MUST + equal `Widget::getId()` from PHP. +3. `src/views/widgets/Widget.vue` — the renderer itself. Wrap in + `` for free loading + empty states. +4. Register in `lib/AppInfo/Application.php`: add + `$context->registerDashboardWidget(Widget::class);`. +5. Add a webpack entry in `webpack.config.js` so `npm run build` produces + `-Widget.js`. + +The `optimization.splitChunks` block in `webpack.config.js` extracts shared +framework code (Vue, `@nextcloud/vue`, pinia, icons) into two chunks loaded +once across the page. Without it every widget bundle would inline ~3 MB of +duplicated framework code per entry-point. See ADR-004 (Build / bundling) +in the hydra repo for the full rationale. + ### Code quality ```bash diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 5e44fde..0bee1d9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -21,6 +21,7 @@ namespace OCA\AppTemplate\AppInfo; +use OCA\AppTemplate\Dashboard\ExampleWidget; use OCA\AppTemplate\Listener\DeepLinkRegistrationListener; use OCA\AppTemplate\Repair\InitializeSettings; use OCA\OpenRegister\Event\DeepLinkRegistrationEvent; @@ -67,6 +68,11 @@ public function register(IRegistrationContext $context): void // Initialize register and schemas on install/upgrade. $context->registerRepairStep(InitializeSettings::class); + // Sample dashboard widget — see lib/Dashboard/ExampleWidget.php. + // Delete this line and the ExampleWidget files if your app has no + // dashboard widgets. + $context->registerDashboardWidget(ExampleWidget::class); + }//end register() /** diff --git a/lib/Dashboard/ExampleWidget.php b/lib/Dashboard/ExampleWidget.php new file mode 100644 index 0000000..5c02de5 --- /dev/null +++ b/lib/Dashboard/ExampleWidget.php @@ -0,0 +1,129 @@ + + * @copyright 2024 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + */ + +declare(strict_types=1); + +namespace OCA\AppTemplate\Dashboard; + +use OCA\AppTemplate\AppInfo\Application; +use OCP\Dashboard\IWidget; +use OCP\IL10N; +use OCP\Util; + +/** + * Example dashboard widget showing the bundling pattern from ADR-004. + */ +class ExampleWidget implements IWidget +{ + /** + * Constructor. + * + * @param IL10N $l10n Localisation service. + */ + public function __construct( + private IL10N $l10n, + ) { + + }//end __construct() + + /** + * Stable widget identifier — must match the string passed to + * `OCA.Dashboard.register(...)` in `src/exampleWidget.js`. + * + * @return string + */ + public function getId(): string + { + return Application::APP_ID.'_example_widget'; + + }//end getId() + + /** + * Title shown in the dashboard widget picker. + * + * @return string + */ + public function getTitle(): string + { + return $this->l10n->t('Example widget'); + + }//end getTitle() + + /** + * Display order. Lower numbers appear first in the picker. + * + * @return int + */ + public function getOrder(): int + { + return 10; + + }//end getOrder() + + /** + * Icon CSS class. Provide a matching `icon-` rule in your CSS. + * + * @return string + */ + public function getIconClass(): string + { + return 'icon-'.Application::APP_ID; + + }//end getIconClass() + + /** + * Optional URL. Returning `null` removes the title-bar link. + * + * @return string|null + */ + public function getUrl(): ?string + { + return null; + + }//end getUrl() + + /** + * Attach the widget's scripts when the dashboard loads. + * + * Order matters — the two shared chunks (emitted by webpack + * `optimization.splitChunks`) MUST load BEFORE the per-widget bundle: + * + * 1. shared-vendor (Vue + pinia + icons) + * 2. shared-nc-vue (@nextcloud/vue + @conduction/nextcloud-vue) + * 3. exampleWidget (the widget's own renderer) + * + * `Util::addScript` dedupes by (app, file), so even when MyDash loads + * every registered widget at once the shared chunks are emitted to the + * HTML exactly once. See ADR-004 (Build / bundling). + * + * @return void + * + * @SuppressWarnings(PHPMD.StaticAccess) — Nextcloud Util API is static by design + */ + public function load(): void + { + Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-shared-vendor'); + Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-shared-nc-vue'); + Util::addScript(application: Application::APP_ID, file: Application::APP_ID.'-exampleWidget'); + + }//end load() +}//end class diff --git a/src/exampleWidget.js b/src/exampleWidget.js new file mode 100644 index 0000000..8e7fb52 --- /dev/null +++ b/src/exampleWidget.js @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 Conduction B.V. +// SPDX-License-Identifier: EUPL-1.2 + +/** + * Dashboard widget renderer. + * + * The first argument to `OCA.Dashboard.register(...)` MUST equal the string + * returned by `ExampleWidget::getId()` in `lib/Dashboard/ExampleWidget.php`. + * If they don't match, Nextcloud's registry silently ignores the callback + * and the widget renders blank — check the browser console for + * `No callback registered for widget ''`. + * + * @see lib/Dashboard/ExampleWidget.php + */ + +import Vue from 'vue' +import { PiniaVuePlugin } from 'pinia' + +import pinia from './pinia.js' +import ExampleWidget from './views/widgets/ExampleWidget.vue' + +Vue.use(PiniaVuePlugin) + +OCA.Dashboard.register('app-template_example_widget', (el, { widget }) => { + Vue.mixin({ methods: { t, n } }) + const View = Vue.extend(ExampleWidget) + new View({ + pinia, + propsData: { title: widget.title }, + }).$mount(el) +}) diff --git a/src/views/widgets/ExampleWidget.vue b/src/views/widgets/ExampleWidget.vue new file mode 100644 index 0000000..e851fad --- /dev/null +++ b/src/views/widgets/ExampleWidget.vue @@ -0,0 +1,65 @@ + + + + diff --git a/webpack.config.js b/webpack.config.js index dbf10e0..688d6af 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,8 +1,10 @@ +// SPDX-License-Identifier: EUPL-1.2 const path = require('path') const fs = require('fs') const webpack = require('webpack') const webpackConfig = require('@nextcloud/webpack-vue-config') const { VueLoaderPlugin } = require('vue-loader') +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin') const buildMode = process.env.NODE_ENV const isDev = buildMode === 'development' @@ -14,6 +16,10 @@ webpackConfig.stats = { } const appId = 'app-template' + +// Each Nextcloud Dashboard widget needs its own webpack entry-point so the +// widget's JS can be attached via `Util::addScript()` from PHP. Add a new +// line here for every widget you create alongside `lib/Dashboard/Widget.php`. webpackConfig.entry = { main: { import: path.join(__dirname, 'src', 'main.js'), @@ -23,46 +29,91 @@ webpackConfig.entry = { import: path.join(__dirname, 'src', 'settings.js'), filename: appId + '-settings.js', }, + exampleWidget: { + import: path.join(__dirname, 'src', 'exampleWidget.js'), + filename: appId + '-exampleWidget.js', + }, } // Use local source when available (monorepo dev), otherwise fall back to npm package const localLib = path.resolve(__dirname, '../nextcloud-vue/src') const useLocalLib = fs.existsSync(localLib) -webpackConfig.resolve = { - extensions: ['.vue', '.js'], - alias: { - '@': path.resolve(__dirname, 'src'), - ...(useLocalLib ? { '@conduction/nextcloud-vue': localLib } : {}), - // Deduplicate shared packages so the aliased library source uses - // the same instances as the app (prevents dual-Pinia / dual-Vue bugs). - 'vue$': path.resolve(__dirname, 'node_modules/vue'), - 'pinia$': path.resolve(__dirname, 'node_modules/pinia'), - '@nextcloud/vue$': path.resolve(__dirname, 'node_modules/@nextcloud/vue'), - }, +// Extend the base resolve config (preserves defaults from @nextcloud/webpack-vue-config) +webpackConfig.resolve = webpackConfig.resolve || {} +webpackConfig.resolve.modules = [path.resolve(__dirname, 'node_modules'), 'node_modules'] +webpackConfig.resolve.alias = { + ...(webpackConfig.resolve.alias || {}), + '@': path.resolve(__dirname, 'src'), + ...(useLocalLib ? { '@conduction/nextcloud-vue': localLib } : {}), + vue$: path.resolve(__dirname, 'node_modules/vue'), + pinia$: path.resolve(__dirname, 'node_modules/pinia'), + '@nextcloud/vue$': path.resolve(__dirname, 'node_modules/@nextcloud/vue'), + '@nextcloud/dialogs': path.resolve(__dirname, 'node_modules/@nextcloud/dialogs'), } -webpackConfig.module = { - rules: [ - { - test: /\.vue$/, - loader: 'vue-loader', - }, - { - test: /\.css$/, - use: ['style-loader', 'css-loader'], - }, - ], -} +// Add SCSS rule to the existing module rules +webpackConfig.module.rules.push({ + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'sass-loader'], +}) +// Replace plugins to avoid duplicate VueLoaderPlugin (base config also registers one). +// CRITICAL: re-add the appName / appVersion DefinePlugin entries — without them +// every @nextcloud/vue widget mount logs `[ERROR] @nextcloud/vue: The library +// was used without setting / replacing the appName`. The base config sets these +// defines, but we lose them when we replace `webpackConfig.plugins` wholesale. +// See ADR-004 (Build / bundling) in hydra/openspec/architecture/. webpackConfig.plugins = [ new VueLoaderPlugin(), + new NodePolyfillPlugin({ additionalAliases: ['process'] }), new webpack.DefinePlugin({ appName: JSON.stringify(appId) }), new webpack.DefinePlugin({ appVersion: JSON.stringify(process.env.npm_package_version) }), ] -// Force @nextcloud/dialogs to resolve from this app's node_modules, -// preventing the nextcloud-vue submodule's nested deps (Vue 3) from leaking in. -webpackConfig.resolve.alias['@nextcloud/dialogs'] = path.resolve(__dirname, 'node_modules/@nextcloud/dialogs') +// Share Vue + @nextcloud/vue + pinia + icons + @conduction/nextcloud-vue across +// every entry-point so each widget bundle no longer inlines its own ~3 MB +// framework copy. Stable filenames (no contenthash in the JS name) mean each +// widget's `Util::addScript` PHP call can reference the chunk directly without +// a manifest. The shared chunks load once on the page and stay cached across +// navigations between this app's pages. +// +// Each widget's PHP `load()` MUST attach the shared chunks before the per-widget +// bundle (see `lib/Dashboard/ExampleWidget.php`). Order in PHP: +// 1. -shared-vendor (Vue, pinia, icons) +// 2. -shared-nc-vue (@nextcloud/vue, @conduction/nextcloud-vue) +// 3. -Widget (your widget code) +// `Util::addScript` dedupes by (app, file) so eagerly loading every widget +// still emits each shared chunk exactly once. +webpackConfig.optimization = { + ...(webpackConfig.optimization || {}), + splitChunks: { + ...(webpackConfig.optimization?.splitChunks || {}), + chunks: 'all', + cacheGroups: { + default: false, + defaultVendors: false, + ncVue: { + name: appId + '-shared-nc-vue', + // Matches both node_modules entries AND the monorepo-dev alias + // `../nextcloud-vue/src/...` which webpack resolves outside + // node_modules when @conduction/nextcloud-vue is aliased to it. + test: /[\\/]node_modules[\\/](@nextcloud[\\/]vue|@conduction[\\/]nextcloud-vue)[\\/]|[\\/]nextcloud-vue[\\/]src[\\/]/, + priority: 30, + reuseExistingChunk: true, + enforce: true, + filename: appId + '-shared-nc-vue.js', + }, + vendor: { + name: appId + '-shared-vendor', + test: /[\\/]node_modules[\\/](vue|pinia|vue-material-design-icons|@vueuse|core-js)[\\/]/, + priority: 20, + reuseExistingChunk: true, + enforce: true, + filename: appId + '-shared-vendor.js', + }, + }, + }, +} module.exports = webpackConfig From c6c94535d30ce4feae2bbed2114b2a249fd74585 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Sat, 9 May 2026 18:32:03 +0200 Subject: [PATCH 06/25] feat(openspec): add template-manifest-v1 change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec the canonical Tier-4 scaffolding for the JSON manifest renderer pattern in nextcloud-app-template. Codifies hydra ADR-024's "new apps MUST adopt the manifest from inception" requirement at the source — the template — rather than retrofitting per app. Includes proposal, design, tasks, and 10 REQ-TMV1-* requirements covering manifest contents, bootstrap pattern, registry contract, webpack alias, dependency floor, and the manifest-first README quickstart. --- .../changes/template-manifest-v1/design.md | 172 ++++++++++++++++++ .../changes/template-manifest-v1/proposal.md | 141 ++++++++++++++ .../specs/template-manifest-v1/spec.md | 172 ++++++++++++++++++ .../changes/template-manifest-v1/tasks.md | 64 +++++++ 4 files changed, 549 insertions(+) create mode 100644 openspec/changes/template-manifest-v1/design.md create mode 100644 openspec/changes/template-manifest-v1/proposal.md create mode 100644 openspec/changes/template-manifest-v1/specs/template-manifest-v1/spec.md create mode 100644 openspec/changes/template-manifest-v1/tasks.md diff --git a/openspec/changes/template-manifest-v1/design.md b/openspec/changes/template-manifest-v1/design.md new file mode 100644 index 0000000..9c02a35 --- /dev/null +++ b/openspec/changes/template-manifest-v1/design.md @@ -0,0 +1,172 @@ +# Design — Template manifest v1 + +## Goal + +Make `nextcloud-app-template` ship the JSON manifest renderer pattern +on first clone. After this change, the canonical "create a new +Conduction Nextcloud app" workflow is: + +1. Clone the template. +2. `npm install && npm run build`. +3. Edit `src/manifest.json`: rename app, swap menu entries, add + pages. +4. Drop a Vue component into `src/customComponents.js` only when a + page is `type: "custom"`. + +No Vue file edits are required to add an index, detail, dashboard, +or settings page. That is the value the template captures. + +## File-by-file inventory (after this change) + +``` +nextcloud-app-template/ +├── src/ +│ ├── manifest.json # NEW — canonical 4-page manifest +│ ├── main.js # REWRITTEN — Tier-4 mount-survivable bootstrap +│ ├── App.vue # REWRITTEN — + #sidebar slot +│ ├── customComponents.js # NEW — empty-by-default registry + 1 example +│ ├── exampleWidget.js # KEPT — Nextcloud Dashboard widget entry +│ ├── settings.js # KEPT — Nextcloud admin settings webpack entry +│ ├── pinia.js # KEPT — Pinia plugin install +│ ├── store/ # KEPT — settings store still used by AdminSettings +│ ├── views/ +│ │ ├── CustomExample.vue # NEW — trivial example custom component +│ │ └── widgets/ +│ │ └── ExampleWidget.vue # KEPT — Nextcloud Dashboard widget +│ └── assets/app.css # KEPT +│ +│ ── DELETED ───────────────── +│ ├── router/index.js # DELETED — routes built from manifest +│ ├── navigation/MainMenu.vue # DELETED — CnAppNav replaces it +│ ├── views/Dashboard.vue # DELETED — manifest type:"dashboard" +│ └── views/settings/ # DELETED — manifest type:"settings" +│ +├── tests/ +│ ├── validate-manifest.js # NEW — Ajv validator (decidesk copy) +│ └── … # KEPT existing PHP test scaffold +│ +├── l10n/ +│ ├── en.json # NEW — empty placeholder +│ ├── en_US.json # NEW — empty placeholder +│ └── nl.json # KEPT +│ +├── package.json # MODIFIED — bump nc-vue to ^1.0.0-beta.12, add check:manifest +├── webpack.config.js # MODIFIED — add @nextcloud/axios$ alias +└── README.md # MODIFIED — manifest-first quickstart +``` + +## Manifest contents (`src/manifest.json`) + +The template manifest is intentionally minimal — 4 pages, 4 menu +entries — so cloners see the shape of every supported page type +(`dashboard`, `index`, `detail`, `settings`) and can copy-paste. + +| Page id | Type | Route | Purpose | +|--------------|-----------|----------------|-------------------------------------------------------------| +| `Dashboard` | dashboard | `/` | Home; one example KPI widget pinned to the OR sample schema | +| `Items` | index | `/items` | Schema-backed list view; demonstrates `register` + `schema` | +| `ItemDetail` | detail | `/items/:id` | Detail view with default audit + data tabs | +| `Settings` | settings | `/settings` | `version-info` widget rich section | + +The OR `register` slug is the placeholder string `"app-template"` and +the `schema` slug is `"item"` — both intentionally placeholder names +that show the cloner where to substitute their own register / schema +slugs. + +## Bootstrap pattern (`src/main.js`) + +Decidesk's mount-survivable pattern (commits `50e4df7c` + `866ff132`) +solves three Vue 2 + frozen-export gotchas the manifest renderer +exposes: + +1. **`Vue.extend()` mutates component options** — Vue 2 caches a + constructor on `_Ctor`. Webpack ESM module records are + non-extensible, so passing `CnPageRenderer` directly throws + "Cannot add property `_Ctor`". Fix: shallow-clone before passing + to `vue-router`. +2. **`defaultPageTypes` is a frozen object** — same root cause; the + lib exports it via the barrel. Fix: shallow-clone before passing + to ``. +3. **`loadTranslations` 404s in dev** — most NC dev installs only + serve JS/CSS through Apache; `/l10n/.json` returns 404, + `loadTranslations` rejects, and wrapping the mount inside its + callback silently kills boot. Fix: mount immediately on + `#content` and fire-and-forget the translation load. + +## Smoke-test recipe (downstream cloner) + +This is the recipe a downstream user runs after cloning to verify +the template still produces a working app: + +```bash +# 1. Clone + rename +git clone https://github.com/ConductionNL/nextcloud-app-template.git my-app +cd my-app + +# 2. Customise app id (search-and-replace 'app-template' → 'my-app' +# in appinfo/info.xml, package.json, openspec/app-config.json, +# src/manifest.json — the manifest does NOT carry the app id; +# main.js passes it via the app-id prop on CnAppRoot). +# +# Pages: edit src/manifest.json. Add menu entries + pages[]; +# set the right `register` + `schema` slugs for your data. +# +# OpenRegister optional? Remove "openregister" from +# manifest.dependencies + appinfo/info.xml + openspec/app-config.json. + +# 3. Install + build +npm install +npm run build +npm run check:manifest # validates src/manifest.json against the schema + +# 4. Mount in Nextcloud +make dev-link # creates ../my-app symlink +docker exec nextcloud php occ app:enable my-app + +# 5. Verify boot +# Browse to http://localhost:8080/index.php/apps/my-app/ +# Expected: +# - CnAppNav renders 3 left-side menu entries (Dashboard, +# Items, Documentation) + Settings in the footer section. +# - Dashboard renders the placeholder widget without errors. +# - Items lists rows from register=app-template / schema=item +# (or "Schema not found" if the cloner hasn't created it +# yet — this is the expected next step, not a template bug). +# - Settings renders the Version widget. +# +# Browser console MUST be free of: +# - "Cannot add property _Ctor, object is not extensible" +# - "[CnAppRoot] manifest is required" +# - "[useAppManifest] schema validation failed" +``` + +If the cloner sees the first error, the bootstrap pattern was +broken (a recent edit reintroduced the frozen-component issue). +If the second, `main.js` is no longer importing `manifest.json`. +If the third, `src/manifest.json` no longer validates against the +installed lib's schema (run `npm run check:manifest` to see why). + +## Out-of-scope (deliberately deferred) + +- A `make new-app NAME=...` scaffolder. Today the rename is a + manual sed-style search-and-replace; a generator would also need + to ship a list of files to template. Worth doing, but separate + scope. +- Removing the OpenRegister wiring from PHP backend (`lib/Service`, + `lib/Repair/InitializeSettings.php`). The template stays + pre-wired for OR because the majority of new Conduction apps + use OR; opting out is a 4-line README delta. +- A "kitchen-sink" manifest demonstrating every config knob the + schema exposes (logs, chat, files page types). Adding too many + example pages raises the cloner's deletion burden. The 4-page + example is calibrated against decidesk's lived experience. +- Backend `/api/manifest` override endpoint. Tier 4 consumers + ship the bundled manifest only; the override hook is opt-in + per ADR-024 §4 and worth its own per-app change. + +## Cleanup follow-up + +None — this change makes the template the canonical Tier-4 +scaffold. There is no follow-up "remove obsolete file X" commit +because the template's pre-manifest shell is fully replaced in +this single change. diff --git a/openspec/changes/template-manifest-v1/proposal.md b/openspec/changes/template-manifest-v1/proposal.md new file mode 100644 index 0000000..a0eb97d --- /dev/null +++ b/openspec/changes/template-manifest-v1/proposal.md @@ -0,0 +1,141 @@ +# Template — manifest v1: scaffold the JSON manifest renderer pattern as the template default + +## Why + +`nextcloud-app-template` is the cookie-cutter every new Conduction +Nextcloud app starts from. Today the template ships a hand-rolled +Vue-router shell (`MainMenu.vue` + `` + per-page Vue +files) that pre-dates the JSON manifest renderer. + +Hydra ADR-024 mandates the opposite default: + +> "Every Conduction app SHOULD ship a `src/manifest.json` validated +> against the canonical schema. **New apps MUST adopt at least Tier 1 +> from inception.**" + +The fleet adoption spec +(`hydra/openspec/changes/adopt-app-manifest/specs/adopt-app-manifest/spec.md`) +adds: every consumer MUST place the manifest at `src/manifest.json`, +load it with `useAppManifest`, and gate the build with +`npm run check:manifest`. + +If the template doesn't ship the manifest pattern, every new app +that scaffolds off this repo starts at Tier 0 and has to migrate +later — the exact cost ADR-024 is trying to eliminate. Decidesk's +20-of-20 `type: "custom"` migration (`decidesk-manifest-v1`) +demonstrates the cost of getting it wrong. + +`@conduction/nextcloud-vue@1.0.0-beta.12` (just published) ships +the full Tier-4 surface: `CnAppRoot`, `CnAppNav`, `CnPageRenderer`, +plus seven page types (`index | detail | dashboard | logs | +settings | chat | files | custom`). All gaps that previously forced +consumers into `type: "custom"` are closed. + +This change rebuilds the template as the canonical Tier-4 +scaffolding for the manifest renderer pattern. After this change, +generating a new app from this template produces a working +manifest-driven shell on first `npm install && npm run build`. + +## What Changes + +- **Rewrite `src/main.js`** to the mount-survivable Tier-4 + bootstrap pattern (decidesk's `50e4df7c` + `866ff132`): + - Import `bundledManifest from './manifest.json'` and pass it to + `` via the App.vue `manifest` prop. + - Build vue-router routes from `manifest.pages[*].{id, route}` + via a `routesFromManifest()` helper. + - Shallow-clone `CnPageRenderer`, `defaultPageTypes`, and + `customComponents` to avoid Vue 2's `Vue.extend()` + "Cannot add property `_Ctor`" errors against the lib's frozen + barrel exports. + - Mount immediately on `#content`; do NOT wait for + `loadTranslations` (NC dev installs commonly 404 the + `/l10n/*.json` route, blocking boot). + +- **Replace `src/App.vue`** with a `` shell: + - `manifest`, `customComponents`, `pageTypes` props from + main.js. + - `#sidebar` slot wired to a `CnObjectSidebar` driven by an + `objectSidebarState` provide/inject channel — the standard + pattern for `CnDetailPage` → host-rendered sidebar. + - `translateForApp(key)` closure passes app-id-aware `t()` + down through CnAppRoot. + +- **Add `src/manifest.json`** as a minimal, valid, opinionated + manifest: + - 4 pages: `Dashboard` (type: `dashboard`), `Items` (type: + `index`), `ItemDetail` (type: `detail`), `Settings` (type: + `settings`). + - 4 menu entries (3 main + Settings in the settings section). + - `dependencies: ["openregister"]` (the template's pre-wired + integration; downstream apps remove this if they don't use OR). + - Settings page demonstrates the `version-info` widget rich + section (the pattern for app-info) — explicitly NOT the + `register-mapping` widget, since that's a per-app schema-list + decision. + +- **Add `src/customComponents.js`** as the empty-by-default + registry, with one example placeholder + (`CustomExample` → `views/CustomExample.vue`) so the registry + has something to demonstrate. + +- **Replace `src/views/Dashboard.vue` + `src/views/settings/` + + `src/navigation/MainMenu.vue` + `src/router/index.js`** + with the manifest-driven equivalents. + +- **Keep `src/views/CustomExample.vue`** as a trivial example + custom component referenced by `customComponents.js`. + +- **Bump `package.json` `@conduction/nextcloud-vue` floor** + to `^1.0.0-beta.12` (the published lib version that includes + the Vue.extend frozen-component fix). + +- **Add the webpack `@nextcloud/axios$` alias** (decidesk's + `ed34703c` pattern) so the lib's axios import resolves to a + single instance. + +- **Add `tests/validate-manifest.js`** copied from + decidesk's reference. Validates `src/manifest.json` against + `node_modules/@conduction/nextcloud-vue/src/schemas/app-manifest.schema.json`. + +- **Add `npm run check:manifest`** to `package.json` scripts + (per the fleet adoption spec). + +- **Add empty `l10n/en.json` + `l10n/en_US.json`** translation + files as placeholders for future strings. + +- **Update `README.md`** with the manifest-first quickstart: + tell users to edit `src/manifest.json` to add pages and only + write a custom Vue component when the page type is `custom`. + +## Reference + +- Hydra ADR-024: + `hydra/openspec/architecture/adr-024-app-manifest.md`. +- Fleet adoption spec: + `hydra/openspec/changes/adopt-app-manifest/specs/adopt-app-manifest/spec.md`. +- Reference consumer migration: + `decidesk/openspec/changes/decidesk-manifest-v1/`. Key commits: + - `b5c88cd2` initial manifest migration + - `4b49bca1` CnAppRoot adoption + cleanup + - `ed34703c` lib bump + webpack alias + - `50e4df7c` mount-survivable bootstrap pattern + - `866ff132` final dep bump to 1.0.0-beta.12 + +## Out of scope + +- Runtime smoke test inside a live Nextcloud instance (the + template has no fleet schemas to point register-mapping at; + smoke is delegated to the first downstream consumer). +- Backend `/api/manifest` override endpoint — Tier 4 consumes + the bundled manifest only; the override hook is opt-in per + ADR-024 §4. +- A `make new-app NAME=...` scaffolder target — left for a + follow-up; this change keeps the existing `make dev-link` + helper. +- Removing the OpenRegister wiring from PHP backend + (`lib/Service`, `lib/Repair/InitializeSettings.php`) — those + remain useful defaults for the OR-dependent majority of new + apps. Apps that don't need OR continue to follow the existing + README guidance ("remove the dependency from `appinfo/info.xml` + and `openspec/app-config.json`"). diff --git a/openspec/changes/template-manifest-v1/specs/template-manifest-v1/spec.md b/openspec/changes/template-manifest-v1/specs/template-manifest-v1/spec.md new file mode 100644 index 0000000..9916025 --- /dev/null +++ b/openspec/changes/template-manifest-v1/specs/template-manifest-v1/spec.md @@ -0,0 +1,172 @@ +--- +status: draft +--- +# Template manifest v1 — JSON manifest renderer scaffold + +## Purpose + +Establish `nextcloud-app-template` as the canonical Tier-4 +scaffolding for the JSON manifest renderer pattern. After this +change, every new Conduction Nextcloud app generated from this +template starts with a working manifest-driven shell. + +This implements hydra ADR-024's "New apps MUST adopt the manifest +from inception" requirement at the source — the template — rather +than retrofitting it into every downstream consumer. + +## ADDED Requirements + +### Requirement: REQ-TMV1-1 The template MUST ship `src/manifest.json` at the canonical location + +The template's `src/manifest.json` MUST exist, MUST be valid JSON, +and MUST set `$schema` to +`https://raw.githubusercontent.com/ConductionNL/nextcloud-vue/main/src/schemas/app-manifest.schema.json`. +The file MUST validate against the v1.x schema published by +`@conduction/nextcloud-vue` with zero errors. + +#### Scenario: validate-manifest passes on first clone +- GIVEN a fresh clone of the template +- AND `npm install` has resolved `@conduction/nextcloud-vue@^1.0.0-beta.12` +- WHEN `node tests/validate-manifest.js` runs +- THEN the script MUST exit with status code 0 +- AND no validation errors MUST be printed + +### Requirement: REQ-TMV1-2 The template manifest MUST include exactly four example pages, one per primary built-in type + +`src/manifest.json` MUST declare exactly four pages, one each of +`type: "dashboard"`, `type: "index"`, `type: "detail"`, and +`type: "settings"`. No `type: "custom"` pages MUST be present in +the template manifest (the registry's example custom component +exists for documentation purposes; the manifest itself MUST NOT +reference it by default). + +#### Scenario: Four pages of distinct types +- GIVEN `src/manifest.json` +- WHEN counting `pages[]` +- THEN the count MUST be exactly 4 +- AND the set `{p.type for p in pages}` MUST equal `{"dashboard", "index", "detail", "settings"}` + +#### Scenario: No custom-type pages by default +- GIVEN `src/manifest.json` +- WHEN counting `pages[*].type === "custom"` +- THEN the count MUST be exactly 0 + +### Requirement: REQ-TMV1-3 The template MUST declare openregister as a default dependency + +`manifest.dependencies` MUST include `"openregister"` as the +template's default. Downstream consumers that do not need +OpenRegister remove the entry per the README guidance; the +template ships it because the majority of Conduction apps use OR. + +#### Scenario: Default openregister dependency +- GIVEN `src/manifest.json` +- WHEN inspecting `manifest.dependencies` +- THEN the array MUST contain `"openregister"` + +### Requirement: REQ-TMV1-4 The template MUST mount `` at Tier 4 + +`src/App.vue` MUST mount `` from +`@conduction/nextcloud-vue` and MUST receive `manifest`, +`customComponents`, and `pageTypes` as props. The `App` component +MUST provide an `objectSidebarState` reactive channel via +`provide()` and MUST mount `` in the `#sidebar` +slot driven by that channel. + +#### Scenario: App.vue is Tier-4 +- GIVEN `src/App.vue` +- WHEN reading the `