feat(vue3): migrate the app template from Vue 2 to Vue 3 - #103
Conversation
This is the template new Conduction apps are generated from, so every day
it stays on Vue 2 the fleet can mint new Vue 2 apps.
Dependencies
- vue 2.7 -> 3.5, vue-router 3 -> 4, @nextcloud/vue 8 -> 9
- @conduction/nextcloud-vue 1.0.0-beta.213 -> 2.1.0-vue3.10 (vue3 dist-tag)
- @nextcloud/dialogs 3 -> 7. NOT 6: despite the higher major, 6.4.2 still
declares `peer vue@^2.7.16` and hard-fails the install. 7.x is the
framework-agnostic line.
- declare the nc-vue peers no consumer was declaring: gridstack, @vueuse/core,
axe-core, dexie, dompurify, marked, @nextcloud/capabilities. They used to
resolve by accident from a hoisted node_modules outside the repo.
- vue-loader 15 -> 17, drop vue-template-compiler, add @vue/compiler-sfc,
@nextcloud/webpack-vue-config 6 -> 7 (the first line declaring Vue 3), and
terser-webpack-plugin, which webpack-vue-config@7 requires but does not
declare.
- the `overrides` block pinned @nextcloud/vue to ^8.39.0 and would have
silently dragged the whole tree back to the Vue 2 line.
Bootstrap
- new Vue()/$mount -> createApp().mount() in main.js, settings.js and
exampleWidget.js; Vue.extend + propsData -> createApp(Comp, props).
- Vue.mixin is global in Vue 2 and per-app in Vue 3; the t/n helpers now
register on each app instance. The old exampleWidget.js registered the
mixin globally from inside the per-widget callback, leaking one copy per
mount.
- PiniaVuePlugin dropped (Vue 3 uses app.use(pinia)).
- new VueRouter({mode:'history'}) -> createRouter({history:createWebHistory}).
- the `path: '*'` catch-all is not a route in Vue Router 4; it silently never
matches. Now `/:pathMatch(.*)*`.
- Vue.observable -> reactive for the objectSidebarState provide/inject channel.
Rendering
- the `@nextcloud/vue$` and `@nextcloud/dialogs` webpack aliases pointed at a
bare package directory. v9/v7 ship an `exports` map and NO `main`, so those
aliases cannot resolve and produced 233 "Can't resolve '@nextcloud/vue'"
errors. resolve.modules already provided the single-instance pin.
Pre-existing bugs fixed on the way through
- main.js never called registerBuiltinDashboardWidgets(), so webpack
tree-shook the entire built-in widget catalogue and the Settings page
rendered "Widget unavailable: version-info". Present on development too.
- openspec/schemas was a committed mode-120000 symlink to
../../../../../../hydra/openspec/schemas. It dangles in every checkout on
this box, nothing references it, and it made `docker cp` of the app refuse
outright ("invalid symlink"), so the app could not be deployed to a
container at all.
- EmailField used the Vue 2 `value` + `@input` model contract, which is not
what `v-model` binds in Vue 3.
Lint
- spread `conductionVue3Fixes` from @conduction/nextcloud-vue/eslint last
over the `@nextcloud` preset. That preset resolves eslint-plugin-vue's
Vue 2 rules: 0 of the 21 `vue/no-deprecated-*` rules were active, so Vue 2
idioms would survive a migration silently. Now 21/21 active.
Before this change the default `chromium` Playwright project had ZERO
specs: the only spec file was the journeydoc docs-capture skeleton, which
the project explicitly ignores, and whose two tests only take screenshots.
There was also no auth wiring at all, so every `page.goto` landed on the
login form.
Config
- PLAYWRIGHT_BASE_URL is now authoritative with NO fallback, and the
config throws on a :8080 base URL outright. The old
`NEXTCLOUD_URL || 'http://localhost:8080'` default silently pointed
local runs at the SHARED dev container.
- tests/integration/*.postman_collection.json defaulted `base_url` to
the same shared container; it now has no default.
- one-time login in tests/e2e/auth.setup.ts, shared via storageState, so
no spec carries credentials or re-logs-in per test.
Suite (tests/e2e/app-shell.spec.ts) — every assertion covers a failure mode
that is SILENT under Vue 3:
- CnAppRoot actually mounts, and App receives its `manifest` prop. Vue 3
moved root props from `render: h => h(App, { props })` to the second
argument of createApp; getting it wrong passes a prop named "props".
- each manifest page renders its own content.
- no widget resolves to `.cn-unknown-widget`.
- nav icons are measured, not just present: an unregistered icon name
renders NOTHING rather than a fallback glyph.
- dashboard widgets are measured for non-zero width — missing the
gridstack stylesheet lays every item out 0px wide with no error, which
toBeVisible() does not catch.
- the catch-all route redirects: Vue Router 4 removed bare `path: '*'`,
and an unmigrated catch-all never matches, leaving <main> empty.
- the admin panel mounts from its separate webpack entry point.
Manifest: two phantom v2 widgetKeys
The suite immediately failed on `.cn-unknown-widget`, which turned out
NOT to be a bootstrap problem but two keys that no registry provides:
- Settings declared `version-info` BOTH as a v1 `config.sections`
widget (which works) and as a v2 `widgetKey` (which cannot — the
v2 BUILT_IN_WIDGETS map has no such key). Removed the duplicate.
- ExampleDetail used `object-data`; the library's key is `data`.
Both were pre-existing and inherited by every generated app.
Uses the shared @conduction/nextcloud-vue/testing/playwright helpers —
seedFirstVisitOverlaysSeen rather than per-test dismissal, which races
the mount.
Every app generated from this repo inherits this text. README, project.md,
docs/ (intro, landing page, docusaurus config) and openspec/config.yaml all
still said 'Vue 2.7'.
openspec/specs/template-manifest-v1/spec.md carried a NORMATIVE requirement
that main.js call 'new Vue(...).$mount(#content)'. That is now the opposite of
what the template does, so it is updated to createApp(...).mount('#content')
rather than left to contradict the code. Archived changes under
openspec/changes/ are history and are left alone.
Adds an End-to-end tests section: PLAYWRIGHT_BASE_URL is now mandatory and
:8080 is rejected, so a new app author would otherwise hit a config throw with
no documentation explaining why.
…ration # Conflicts: # package-lock.json
`npm run lint` only ran `eslint src`, so nothing under tests/ was ever checked. The eslint config's node-CLI override was also an explicit FILE LIST, so tests/l10n/check-l10n-parity.js (added by #99) fell outside it and would have failed the moment anyone widened the glob. - lint script -> `eslint src tests --max-warnings 0` - node-CLI override: explicit file list -> `tests/**/*.js` glob - `n/no-unpublished-import` off for tests/ (devDependencies by definition) - filled in the JSDoc the autofix stubbed out rather than leaving bare `@param name` lines Two real findings, not style: - tests/validate-json-strict.js declared `reviverPathStack` for a reviver-based approach that was never built. Dead binding, removed. - tests/validate-register.js kept `lifecycleStates()` alive with a `void lifecycleStates` suppression and never called it. The inline check it was meant to replace only inspected `t.to`, and only when `states{}` was present — an undeclared `from` state, or an array `from`, was silently unreported. Now wired up properly: pass it just the transitions block so it yields the REFERENCED states (passing the whole lifecycle would fold the declared states in and make the comparison vacuous). validate-register still reports 0 warnings on the template's own register. Result: 0 errors and 0 warnings across src/ and tests/.
Quality Report — ConductionNL/nextcloud-app-template @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ❌ 1/737 denied | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
❌ Denied npm licenses
| Package | Version | License |
|---|---|---|
| vue3-apexcharts | 1.10.0 | Custom: https://apexcharts.com/media/vue-apexcharts.png |
Quality workflow — 2026-07-31 13:57 UTC
Download the full PDF report from the workflow artifacts.
… nc-vue exactly Two separate lockfile faults, both of which passed local checks and only failed in CI. 1. npm version skew. `engines` pins npm ^10 / node ^20, and CI honours it. The lockfile here was generated with npm 11 / node 22, which records unmet peers differently: npm 10 then refused it with `npm ci can only install packages when your package.json and package-lock.json are in sync — Missing: pinia@4.0.2 from lock file`. The demand comes from vue-router@5 nested inside @nextcloud/vue@9, which peer-requires `pinia ^3.0.4 || ^4.0.2` while @conduction/nextcloud-vue peer-requires `pinia ^2.0.0`. npm 11 elided that conflict; npm 10 wants it materialised. Regenerated with `npx npm@10`, and verified with a `npm@10 ci --dry-run` rather than the npm 11 one that reported "up to date" against an already-populated node_modules. Note this is the same failure class already sitting on `development` (`Missing: text-table@0.2.0`), so `npm ci` is currently broken there too. 2. `^2.1.0-vue3.10` is not a pin. A caret range over a prerelease DOES float: `2.1.0-vue3.11` was published mid-session and a later `npm install` silently picked it up, moving the `vue3` dist-tag out from under the build. Pinned exactly to `2.1.0-vue3.10` so the template resolves reproducibly instead of tracking whatever was published last. Re-verified against the regenerated tree: lint 0/0, stylelint clean, check:specs 5 PASS, l10n parity 36/36, webpack 0 errors, e2e 8 passed.
Quality Report — ConductionNL/nextcloud-app-template @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ❌ 1/738 denied | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
❌ Denied npm licenses
| Package | Version | License |
|---|---|---|
| vue3-apexcharts | 1.10.0 | Custom: https://apexcharts.com/media/vue-apexcharts.png |
Quality workflow — 2026-07-31 14:07 UTC
Download the full PDF report from the workflow artifacts.
…ration # Conflicts: # package-lock.json
Quality Report — ConductionNL/nextcloud-app-template @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ❌ 1/738 denied | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
❌ Denied npm licenses
| Package | Version | License |
|---|---|---|
| vue3-apexcharts | 1.10.0 | Custom: https://apexcharts.com/media/vue-apexcharts.png |
Quality workflow — 2026-07-31 14:15 UTC
Download the full PDF report from the workflow artifacts.
`quality / License (npm)` was failing, and it was MY regression, not part of
the pre-existing set inherited from development. Reproduced locally against
the reusable workflow's own logic (ConductionNL/.github quality.yml, job
"License (npm)"):
DENIED: vue3-apexcharts (1.10.0) uses
'Custom: https://apexcharts.com/media/vue-apexcharts.png'
This is a TRUE POSITIVE, not an allowlist gap. vue3-apexcharts relicensed at
exactly 1.9.0:
1.8.0 MIT
1.9.0 "see LICENSE in LICENSE" <-- relicensed here
1.10.0 "see LICENSE in LICENSE"
Its LICENSE file is a proprietary dual-license: free only for organisations
under $2M USD annual revenue, a paid Commercial License at or above that, and
a paid OEM/Redistribution License for "no-code dashboards, developer
platforms, embedded BI tools, white-labeled apps or SDKs". Those are
revenue-capped field-of-use restrictions, which an EUPL-1.2 app cannot carry
downstream — and redistributing it inside a component library is squarely the
OEM case. It is not compatible with this repo's licence, so suppressing it
with a .license-overrides.json entry would have been wrong.
It arrived transitively: @conduction/nextcloud-vue@2.1.0-vue3.10 declares
`"vue3-apexcharts": "~1.10.0"` as a hard dependency, so the range sits
ENTIRELY inside the proprietary window and a fresh consumer gets proprietary
code by default. The Vue 2 line pulled `vue-apexcharts` instead, which is why
this appeared only on the Vue 3 bump.
Pinned via npm `overrides` to `<1.9.0`, matching the mitigation already in
openconnector's package.json. This is a real fix rather than a downgrade
hack: 1.8.0 is MIT and its peers are `vue >=3.0.0` + `apexcharts >=4.0.0`,
both satisfied here (vue 3.5.40, apexcharts 4.7.0 — the core `apexcharts`
package is still MIT at 4.7.0; only the Vue wrapper relicensed).
Verified: license check 0 violations (was 1), vue3-apexcharts resolves to
1.8.0 / MIT, npm@10 ci clean, lint 0/0, stylelint clean, check:specs 5 PASS,
l10n parity 36/36, webpack 0 errors, e2e 8 passed.
NOTE FOR nc-vue: the per-consumer override is a workaround every app must
remember independently. nc-vue should pin or drop the dependency itself.
Quality Report — ConductionNL/nextcloud-app-template @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ❌ | ||||
| phpmd | ❌ | ||||
| psalm | ❌ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| composer | ❌ | ✅ 100/100 | |||
| npm | ✅ | ✅ 738/738 | |||
| PHPUnit | ⏭️ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-07-31 14:40 UTC
Download the full PDF report from the workflow artifacts.
|
| version | license field |
|---|---|
| 1.8.0 | MIT |
| 1.9.0 | see LICENSE in LICENSE |
| 1.10.0 | see LICENSE in LICENSE |
Its LICENSE is a proprietary dual-license: free only under $2M USD annual revenue, paid Commercial above that, and a paid OEM/Redistribution licence for "no-code dashboards, developer platforms, embedded BI tools, white-labeled apps or SDKs". Those are revenue-capped field-of-use restrictions that an EUPL-1.2 app cannot pass downstream, and shipping it inside a component library is squarely the OEM case. An allowlist/override entry would have suppressed a real finding.
It arrived transitively: @conduction/nextcloud-vue@2.1.0-vue3.10 declares "vue3-apexcharts": "~1.10.0" as a hard dependency — the range sits entirely inside the proprietary window, so a fresh consumer gets proprietary code by default. The Vue 2 line pulled vue-apexcharts instead, which is why this surfaced only on the Vue 3 bump.
Fix
npm overrides pin to <1.9.0, matching the mitigation already present in openconnector. A real fix, not a downgrade hack: 1.8.0 is MIT, its peers are vue >=3.0.0 + apexcharts >=4.0.0, both satisfied (vue 3.5.40, apexcharts 4.7.0). The core apexcharts package is still MIT at 4.7.0 — only the Vue wrapper relicensed.
Verified after the pin: vue3-apexcharts@1.8.0 license: MIT, license check 0 violations, npm@10 ci clean, lint 0/0, stylelint clean, check:specs 5 PASS, l10n parity 36/36, webpack 0 errors, e2e 8 passed. quality / License (npm) now passes in CI.
Residual set — now verified identical
Same workflow (Code Quality), both sides, completed runs:
development (cf4c54bc) PR #103 (ca1f51fd)
Features Check Features Check
PHP Quality (phpcs) PHP Quality (phpcs)
PHP Quality (phpmd) PHP Quality (phpmd)
PHP Quality (psalm) PHP Quality (psalm)
Quality Report Quality Report
Security (composer) Security (composer)
diff -> IDENTICAL (6 vs 6)
Quality Report is a pure aggregator (needs: [all jobs] + a final "Gate — fail when any upstream job failed" step), so it fails iff one of the other five does. This PR touches zero PHP or composer files.
Two items for fleet policy (not changed here)
- nc-vue should pin or drop
vue3-apexchartsitself.~1.10.0means every consumer on the Vue 3 line inherits a proprietary dependency and must independently remember the same override. - The shared
check_licensedoes not handle SPDXAND. It splits onORonly, soMIT AND Zlibfails even though both halves are on the allowlist — which is why this repo carries manual.license-overrides.jsonentries forpakoandsha.js. Splitting onANDand requiring all parts to be allowed would be strictly correct and would remove those overrides fleet-wide.
Migrates the app template from Vue 2 to Vue 3. This repo is what new Conduction apps are generated from, so every day it stayed on Vue 2 the fleet could mint new Vue 2 apps.
Results
npm run lintsrconly, and 0 of 21vue/no-deprecated-*rules activesrcandtests, 21/21 deprecation rules activenpm run buildchromiumnpm run check:specsVerified against a disposable, isolated instance on
:8096(docker inspect … Mounts=volume; the sharednextcloudcontainer reports 39bindmounts). Deployed bundle SHA checked against the on-disk build on every run.Dependencies
vue2.7→3.5.40 ·vue-router3→4.6.4 ·@nextcloud/vue8→9.9.0 ·@conduction/nextcloud-vue1.0.0-beta.213→2.1.0-vue3.10(thevue3dist-tag) ·vue-loader15→17 ·@nextcloud/webpack-vue-config6→7.Three traps worth naming:
overridespinned@nextcloud/vue: ^8.39.0. Bumpingdependenciesalone would have silently dragged the whole tree back onto the Vue 2 line.@nextcloud/dialogs@6.4.2still declarespeer vue@^2.7.16despite the higher major, and hard-fails the install.7.4.1is the framework-agnostic line.gridstack,@vueuse/core,axe-core,dexie,dompurify,marked,@nextcloud/capabilitiesas peers and no consumer declared any of them — they resolved by accident from a hoistednode_modules.@nextcloud/webpack-vue-config@7additionallyrequire()sterser-webpack-pluginwithout declaring it. All now declared.Rendering fix
@nextcloud/vue@9and@nextcloud/dialogs@7ship anexportsmap and nomain, so the existing'@nextcloud/vue$': path.resolve(…/node_modules/@nextcloud/vue)webpack aliases became unresolvable — 233Can't resolve '@nextcloud/vue'errors. Removed;resolve.modulesalready provided the single-instance pin those aliases were buying.Pre-existing bugs fixed on the way through
openspec/schemaswas a committedmode-120000symlink to../../../../../../hydra/openspec/schemas. It dangles in every checkout on this box, nothing references it, and it madedocker cpof the app refuse outright (invalid symlink) — the app could not be deployed to a container at all. Same failure class as openconnector's committednode_modulessymlink.widgetKeys.Settingsdeclaredversion-infoboth as a v1config.sectionswidget (works) and as a v2widgetKey(no such key in the v2BUILT_IN_WIDGETSmap);ExampleDetailusedobject-datawhere the library's key isdata. Both rendered.cn-unknown-widget— "Widget unavailable" — with zero console output.tests/was never linted (npm run lintwaseslint src), and the eslint node-CLI override was an explicit file list, so the checker added by i18n: full European language coverage (36 locales) + parity gate #99 fell outside it. Fixing that surfaced two real findings: a deadreviverPathStackbinding, andlifecycleStates()kept alive by avoid lifecycleStatessuppression while never being called — the inline check it was meant to replace only inspectedt.to, and only whenstates{}existed, so an undeclared or arrayfromstate went unreported. Now wired up correctly.EmailFieldused the Vue 2value+@inputmodel contract, which is not whatv-modelbinds in Vue 3.Tests
The default
chromiumproject had zero specs. Addedtests/e2e/app-shell.spec.ts(7 tests) plus a shared one-time login. Every assertion covers a failure mode that is silent under Vue 3 — root props reachingApp, no.cn-unknown-widget, nav icons measured (an unregistered icon name renders nothing, not a fallback), dashboard widgets measured for non-zero width (missing gridstack CSS lays items out 0 px wide andtoBeVisible()does not catch it), the Vue Router 4 catch-all, and the separate admin-settings entry point.Two of these were proven red before green:
.cn-unknown-widgettest failed on first run (Expected: 0, Received: 1) and found bug Add appspec/openspec scaffold, dev tooling, and CI-ready test infrastructure #2 above;path: '*', rebuilding and redeploying reproduced a genuine failure at thetoHaveURLassertion.Shared-instance safety.
playwright.config.tshadNEXTCLOUD_URL || 'http://localhost:8080'and the Newman collection defaultedbase_urlto the same.:8080is the shared dev container, which bind-mounts real host checkouts.PLAYWRIGHT_BASE_URLis now authoritative with no fallback, and the config throws on a:8080base URL.Template hygiene
README,
project.md,docs/andopenspec/config.yamlall described the template as "Vue 2.7" — text every generated app inherits.openspec/specs/template-manifest-v1/spec.mdcarried a normative requirement thatmain.jscallnew Vue(...).$mount('#content'), which the code now contradicts; updated tocreateApp(...).mount('#content'). Archived changes underopenspec/changes/are history and left alone. Added an End-to-end tests section, since the config now refuses to guess a base URL and a new app author would otherwise hit an unexplained throw.Outstanding PRs handled first
main→developmentand merged. Zero overlap with the migration; gate verified passing before landing.@conduction/nextcloud-vue→beta.220) — closed as superseded.beta.*is the Vue 2 line; merging it would have moved the dependency backwards.developmentalready carries 3.4.5.developmentalready carries exactly v3.26.0.main.Known residuals
dist/esm/composables/cnFormFieldRenderer.jsuses a CommonJSrequire('@nextcloud/vue')inside an ESM dist file.@nextcloud/vue@9'sexportsmap has norequirecondition, so this can never resolve — it emits a build warning in every consumer and, because it is wrapped intry/catch, permanently forcesNcTextArea = nullwith no runtime error.composer.json.