From 26f5fe75a9626cb78d723d6f3bb8ae27823f9448 Mon Sep 17 00:00:00 2001 From: Theo <36564257+theoholl@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:18:11 +0000 Subject: [PATCH 01/40] Start planning the migration Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com> --- docs/vue3-dependency-audit.md | 175 +++++++++++++++++++++++++++++++ docs/vue3-migration-plan.md | 191 ++++++++++++++++++++++++++++++++++ mkdocs.yml | 3 + 3 files changed, 369 insertions(+) create mode 100644 docs/vue3-dependency-audit.md create mode 100644 docs/vue3-migration-plan.md diff --git a/docs/vue3-dependency-audit.md b/docs/vue3-dependency-audit.md new file mode 100644 index 0000000000..40097c1942 --- /dev/null +++ b/docs/vue3-dependency-audit.md @@ -0,0 +1,175 @@ + +# Vue 3 Dependency Audit + +Use this file to track package-level migration status before changing the Vue runtime. + +Status values: + +- `pending`: not checked yet +- `upgrade`: compatible upgrade path exists +- `replace`: package should be replaced +- `remove`: package can be removed entirely +- `blocked`: migration currently blocked on this package + +## Core framework packages + +| Package | Current usage | Status | Action | Notes | +| --- | --- | --- | --- | --- | +| `vue` | runtime | blocked | target `3.5.x` and remove compat-only APIs | Current lockfile is `2.7.16`; move to the stable Vue 3.5 line once the Nextcloud UI layer is ready. | +| `vue-loader` | SFC build | upgrade | keep `17.4.2+` and pair it with the Vue 3 compiler stack | Current `17.4.2` is already on the modern loader line and does not itself block the migration. | +| `vue-router` | main app router | blocked | target `4.3.x` or newer stable `4.4.x` | Current `3.6.5` is the Vue 2 router line. | +| `vuex` | main, overview, dashboard stores | blocked | target `4.1.x` | Current `3.6.2` has a Vue 2 peer dependency. Keep the store migration scoped and avoid combining it with Pinia. | +| `vuex-router-sync` | router-state sync | replace | remove dependency and sync route state manually where needed | Current `5.0.0` peers on Router 3 and Vuex 3 only, and the repo uses it only in [../src/main.js](../src/main.js). | +| `@vue/test-utils` | component tests | upgrade | keep `2.4.6+` and verify final test setup after runtime switch | Current `2.4.6` is already the Vue 3 test-utils generation. No frontend tests currently reference it. | +| `@vue/vue2-jest` | Vue test transform | replace | switch to a Vue 3-compatible Jest transformer | Current `29.2.6` peers on `vue ^2.x` and `vue-template-compiler ^2.x`. | +| `vue-template-compiler` | Vue 2 SFC compiler | remove | replace with the Vue 3 compiler package | Current `2.7.16` is Vue 2-only. | + +## Nextcloud integration packages + +| Package | Current usage | Status | Action | Notes | +| --- | --- | --- | --- | --- | +| `@nextcloud/vue` | shared UI components, composables, reference helpers, deep imports | blocked | upgrade to the first Vue 3-compatible major published by Nextcloud, likely `9.x` or `10.x` | Current `8.35.0` depends on `vue ^2.7.16`, `vue-router ^3.6.5`, `@nextcloud/vue-select` with `vue 2.x`, `vue2-datepicker`, and other Vue 2 packages. This is the main ecosystem blocker and the exact target major is still unconfirmed. | +| `@nextcloud/dialogs` | toasts, undo, loading, file picker helpers | blocked | upgrade in lockstep with the first Vue 3-compatible `@nextcloud/vue` major, likely `7.x` or `8.x` | Current `6.4.2` peers on `@nextcloud/vue ^8.24.0` and `vue ^2.7.16`. The exact target major is still unconfirmed. | +| `@nextcloud/event-bus` | app event integration | upgrade | keep `3.3.x+` unless a newer Nextcloud bundle version is required by upgraded UI packages | Current `3.3.3` has no Vue peer dependency and should not block the runtime switch by itself. | +| `@nextcloud/webpack-vue-config` | webpack baseline | upgrade | keep `6.3.0+` and align it with the final Vue version and compiler stack | Current `6.3.0` explicitly peers on `vue ^2.7.16 || ^3.5.13` and `vue-loader ^15 || ^17`, so the config layer is already migration-friendly. | + +## Vue-adjacent UI and behavior packages + +| Package | Current usage | Status | Action | Notes | +| --- | --- | --- | --- | --- | +| `vue-click-outside` | click-outside directive | replace | replace with a local Vue 3 directive | The repo uses it broadly as a directive in [../src/main.js](../src/main.js), [../src/init-reference.js](../src/init-reference.js), and multiple components. Replacing it locally is simpler than carrying a legacy plugin. | +| `vue-easymde` | markdown editor wrapper | replace | preferred: wrap `easymde` directly in a local Vue 3 component; fallback: evaluate `@nextcloud/text` if it can fully cover the current editor flow | The repo imports `vue-easymde/dist/VueEasyMDE.common.js` and reaches into wrapper internals via refs in [../src/components/card/Description.vue](../src/components/card/Description.vue), which makes this a high-risk migration point. | +| `vue-infinite-loading` | activity, search, comments infinite lists | replace | preferred: local `IntersectionObserver` wrapper using `@vueuse/core`; fallback: a Vue 3 virtual-scroll library for longer lists | Current `2.4.5` peers on `vue ^2.6.10`. Used in [../src/components/ActivityList.vue](../src/components/ActivityList.vue), [../src/components/search/GlobalSearchResults.vue](../src/components/search/GlobalSearchResults.vue), and [../src/components/card/CardSidebarTabComments.vue](../src/components/card/CardSidebarTabComments.vue). | +| `vue-smooth-dnd` | board and stack drag and drop | replace | preferred: `@dnd-kit/vue`; fallback: `sortablejs` with a thin local Vue 3 wrapper | Central interaction dependency used in [../src/components/board/Board.vue](../src/components/board/Board.vue) and [../src/components/board/Stack.vue](../src/components/board/Stack.vue). Keeping it would add avoidable migration risk. | +| `vue-at` | mentions | remove | remove dependency unless a hidden integration is discovered outside `src/` | No usage was found in `src/`; the current mention UI appears to come from Nextcloud components instead. | +| `vue-material-design-icons` | icon components | upgrade | keep `5.3.1+` and verify import format after runtime switch | Used as SFC icon components, for example in [../src/components/card/CommentItem.vue](../src/components/card/CommentItem.vue). This is lower risk than the interactive wrappers above. | + +## Concrete target summary + +### Upgrade targets + +| Package | Recommended target | +| --- | --- | +| `vue` | `3.5.x` | +| `vue-loader` | `17.4.2+` | +| `vue-router` | `4.3.x` or newer stable `4.4.x` | +| `vuex` | `4.1.x` | +| `@vue/test-utils` | `2.4.6+` | +| `@nextcloud/event-bus` | `3.3.x+` | +| `@nextcloud/webpack-vue-config` | `6.3.0+` | +| `vue-material-design-icons` | `5.3.1+` | + +### Replacement targets + +| Current package | Recommended replacement | +| --- | --- | +| `vue-click-outside` | local `v-click-outside` directive in `src/directives/` | +| `vue-easymde` | local Vue 3 wrapper around `easymde` | +| `vue-infinite-loading` | local observer-based pagination wrapper built on `@vueuse/core` | +| `vue-smooth-dnd` | `@dnd-kit/vue` | +| `vuex-router-sync` | remove package and sync route state manually | +| `@vue/vue2-jest` | `@vue/vue3-jest` | +| `vue-template-compiler` | `@vue/compiler-sfc` | + +### Fallback replacement options + +| Current package | Fallback option | +| --- | --- | +| `vue-easymde` | evaluate `@nextcloud/text` if it covers the full editing flow | +| `vue-infinite-loading` | Vue 3 virtual-scroll library for long lists | +| `vue-smooth-dnd` | `sortablejs` with a thin local wrapper | + +## Isolation-first wrapper candidates + +These are the packages that should be hidden behind local abstractions before the runtime switch. The goal is to reduce the number of direct call sites that need to change when Vue 3 work begins. + +### Priority 1: Small, high-spread wrappers + +| Package | Why isolate first | Current footprint | Suggested local seam | +| --- | --- | --- | --- | +| `vue-click-outside` | Small behavior surface, many call sites, easy to replace without behavior changes | Used in [../src/main.js](../src/main.js), [../src/components/board/Stack.vue](../src/components/board/Stack.vue), [../src/components/navigation/AppNavigation.vue](../src/components/navigation/AppNavigation.vue), [../src/components/navigation/AppNavigationBoard.vue](../src/components/navigation/AppNavigationBoard.vue), [../src/components/cards/CardItem.vue](../src/components/cards/CardItem.vue), and other templates | local `src/directives/clickOutside.js` plus centralized registration | +| `vuex-router-sync` | Single import today, but it couples the app shell to Router 3 and Vuex 3 | Only used in [../src/main.js](../src/main.js) | local route-to-store sync helper in `src/router/` or `src/store/` | +| `@nextcloud/dialogs` | Broad usage, but much of it is already thin helper-style code | Used directly in board, card, navigation, and upload flows; partially wrapped already by [../src/helpers/errors.js](../src/helpers/errors.js) | extend local helpers such as `src/helpers/errors.js` and add a `src/helpers/dialogs.js` facade | + +### Priority 2: Medium-scope component wrappers + +| Package | Why isolate next | Current footprint | Suggested local seam | +| --- | --- | --- | --- | +| `vue-infinite-loading` | Only three components depend on it, and the contract is simple: load-more on sentinel reach | Used in [../src/components/ActivityList.vue](../src/components/ActivityList.vue), [../src/components/search/GlobalSearchResults.vue](../src/components/search/GlobalSearchResults.vue), and [../src/components/card/CardSidebarTabComments.vue](../src/components/card/CardSidebarTabComments.vue) | local `InfiniteLoader` component backed by `IntersectionObserver` | +| `@nextcloud/vue/dist/...` deep imports | High break risk during package upgrades because they rely on internals | Seen in [../src/init-reference.js](../src/init-reference.js), [../src/views/BoardReferenceWidget.vue](../src/views/BoardReferenceWidget.vue), and [../src/components/card/CardSidebar.vue](../src/components/card/CardSidebar.vue) | local wrapper exports under `src/lib/nextcloud-vue.js` or feature-local adapters | + +### Priority 3: Complex interactive wrappers + +| Package | Why isolate later | Current footprint | Suggested local seam | +| --- | --- | --- | --- | +| `vue-easymde` | Single main usage, but deep custom behavior and editor-internal access | Concentrated in [../src/components/card/Description.vue](../src/components/card/Description.vue) | local `DeckMarkdownEditor` component | +| `vue-smooth-dnd` | Only two files import it, but the interaction model and CSS coupling are deep | Used in [../src/components/board/Board.vue](../src/components/board/Board.vue) and [../src/components/board/Stack.vue](../src/components/board/Stack.vue) | local `BoardDnDContainer` and `CardDnDContainer` wrappers or a thin `src/lib/dnd/` adapter | + +### Isolation order recommendation + +1. Introduce a local click-outside directive. +2. Replace `vuex-router-sync` with a local route sync helper. +3. Funnel all direct dialog calls through local helper modules. +4. Add a local infinite-loader component. +5. Remove deep `@nextcloud/vue/dist/...` imports behind local adapters. +6. Wrap the Markdown editor. +7. Replace drag-and-drop behind local DnD adapters. + +## Repo-specific findings + +- Current Nextcloud UI packages in the lockfile are still on Vue 2. `@nextcloud/vue` and `@nextcloud/dialogs` are both hard blockers for a direct runtime switch. +- The webpack baseline is not the blocker. `@nextcloud/webpack-vue-config` already advertises support for both Vue 2 and Vue 3 peer ranges. +- Deep imports from `@nextcloud/vue/dist/...` increase migration risk because they couple Deck to package internals. Current examples include [../src/init-reference.js](../src/init-reference.js), [../src/views/BoardReferenceWidget.vue](../src/views/BoardReferenceWidget.vue), and [../src/components/card/CardSidebar.vue](../src/components/card/CardSidebar.vue). +- Several third-party packages are better replaced than upgraded because Deck uses them as thin adapters: click-outside, infinite loading, drag and drop, and the Markdown editor wrapper. +- No frontend tests currently reference `@vue/test-utils`, so test tooling work will mostly be setup work rather than fixture migration. +- The exact Vue 3-compatible major versions of `@nextcloud/vue` and `@nextcloud/dialogs` still need confirmation from published Nextcloud releases or maintainers. Until that is confirmed, the rest of the package targets should be treated as provisional. + +## Audit checklist + +- [x] Check each package for Vue 3 support, peer dependencies, and maintenance status. +- [x] Record the exact target version for every package marked `upgrade`. +- [x] Record the replacement candidate for every package marked `replace`. +- [x] Identify packages that can be isolated behind local wrappers before the runtime switch. +- [ ] Confirm test tooling changes required for the final package set. + +### Current blockers identified on 2026-03-21 + +- [x] `vue` is still pinned to the Vue 2 line. +- [x] `vue-router`, `vuex`, and `vuex-router-sync` are still pinned to the Vue 2 ecosystem. +- [x] `@nextcloud/vue` current major is Vue 2-based. +- [x] `@nextcloud/dialogs` current major peers on Vue 2. +- [x] `vue-infinite-loading` is Vue 2-only. +- [x] `vue-at` appears unused and can be removed. +- [x] Exact target replacement libraries have been chosen for the packages currently marked `replace`. + +## Suggested verification commands + +Run these commands while updating this file: + +```bash +npm view peerDependencies dependencies version --json +npm view dist-tags --json +npm info repository homepage +``` + +## Decision log + +| Package | Decision | Date | Reason | +| --- | --- | --- | --- | +| `@nextcloud/vue` | blocked on current major | 2026-03-21 | Lockfile shows `8.35.0` still depends on Vue 2, Vue Router 3, and multiple Vue 2-only transitive packages. The likely target is a future `9.x` or `10.x` major, but that is still unconfirmed. | +| `@nextcloud/dialogs` | blocked on current major | 2026-03-21 | Lockfile shows `6.4.2` peers on Vue 2 and `@nextcloud/vue` 8.x. The likely target is a future `7.x` or `8.x` major, but that is still unconfirmed. | +| `@nextcloud/webpack-vue-config` | upgrade path available | 2026-03-21 | Lockfile shows `6.3.0` already advertises Vue 3 peer compatibility. | +| `vuex-router-sync` | replace | 2026-03-21 | The package is tightly coupled to Router 3 and Vuex 3, and repo usage is isolated to the main app bootstrap. | +| `vue-at` | remove | 2026-03-21 | No imports were found in `src/`, so it is currently dead weight for the migration. | +| `vue-easymde` | replace | 2026-03-21 | Deck reaches into wrapper internals in [../src/components/card/Description.vue](../src/components/card/Description.vue), so a local wrapper around `easymde` is safer than looking for a drop-in Vue 3 port. | +| `vue-smooth-dnd` | replace | 2026-03-21 | `@dnd-kit/vue` is the preferred target because it is Vue 3-native and modern; `sortablejs` remains the fallback if the interaction model fits better. | +| `vue-click-outside` | isolate first | 2026-03-21 | It has a small API surface and many call sites, so replacing it early cuts migration noise across the app. | +| `@nextcloud/dialogs` | isolate first | 2026-03-21 | Direct calls are spread across the app, but they already behave like helper functions and can be centralized behind local wrappers. | + +## Blocking conditions + +- [ ] No package remains in `blocked` state without an explicit mitigation plan. +- [x] The migration-build decision should be revisited only after the Nextcloud package upgrade path is known. \ No newline at end of file diff --git a/docs/vue3-migration-plan.md b/docs/vue3-migration-plan.md new file mode 100644 index 0000000000..d588f1b251 --- /dev/null +++ b/docs/vue3-migration-plan.md @@ -0,0 +1,191 @@ + +# Vue 3 Migration Implementation Plan + +This document is the working implementation plan for migrating Deck from Vue 2.7 to Vue 3. + +Use this file as the source of truth for sequencing, progress tracking, and exit criteria. + +## Migration principles + +- [ ] Keep the initial migration behavior-compatible. Avoid feature work in the same branch. +- [ ] Keep the Options API during the migration unless a file already needs a deeper rewrite. +- [ ] Migrate one workstream at a time and keep each step independently reviewable. +- [ ] Use the Vue 3 migration build only as a temporary compatibility aid, not as the end state. +- [ ] Remove Vue 2-only patterns before or during the runtime switch instead of layering workarounds on top. + +## Target outcomes + +- [ ] Run the main app on Vue 3. +- [ ] Run dashboard, reference widgets, and collaboration pickers on Vue 3. +- [ ] Replace Vue 2 instance APIs and deprecated template patterns. +- [ ] Keep the existing route structure and user-visible behavior intact. +- [ ] Restore green linting, tests, and production build output. + +## Phase 0: Preparation and baseline + +- [ ] Create a dedicated migration branch. +- [ ] Record the current baseline build and test status. +- [ ] Confirm the supported Nextcloud and Node.js versions for the migration target. +- [x] Audit Vue-related dependencies in [vue3-dependency-audit.md](vue3-dependency-audit.md). +- [ ] Confirm the first Vue 3-compatible major versions of `@nextcloud/vue` and `@nextcloud/dialogs` from published releases or maintainers. +- [ ] Decide which packages can be upgraded directly and which need replacement or isolation. +- [x] Record the target versions and replacement candidates from [vue3-dependency-audit.md](vue3-dependency-audit.md). +- [x] Identify the first local wrapper seams from [vue3-dependency-audit.md](vue3-dependency-audit.md). +- [ ] Decide whether a temporary migration-build branch is needed after the Nextcloud package upgrade path is confirmed. + +### Exit criteria + +- [ ] All Vue-facing dependencies are categorized as `upgrade`, `replace`, `remove`, or `verify later`. +- [ ] Known blockers are listed before touching the runtime. +- [ ] The Nextcloud UI package upgrade path is confirmed, or an explicit mitigation plan exists for waiting on it. + +## Phase 1: Remove Vue 2-only application patterns + +### 1.1 Bootstrapping and app globals + +- [ ] Replace `new Vue(...)` mounting in [../src/main.js](../src/main.js). +- [ ] Replace `Vue.prototype` usage in [../src/main.js](../src/main.js), [../src/init-collections.js](../src/init-collections.js), [../src/init-dashboard.js](../src/init-dashboard.js), [../src/init-reference.js](../src/init-reference.js), and [../src/init-talk.js](../src/init-talk.js). +- [ ] Introduce shared Vue 3 mount helpers for standalone entrypoints. +- [ ] Move global properties, directives, and plugins to app-level registration. + +### 1.1a Build isolation seams first + +- [ ] Introduce a local `clickOutside` directive and replace direct `vue-click-outside` imports. +- [ ] Replace `vuex-router-sync` with a local route-store sync helper. +- [ ] Funnel direct `@nextcloud/dialogs` calls through local helper modules. +- [ ] Replace deep `@nextcloud/vue/dist/...` imports with local adapters. +- [ ] Introduce a local infinite-loader component before the Vue 3 runtime switch. + +### 1.2 Manual widget lifecycle management + +- [ ] Replace `Vue.extend(...)` usage in [../src/init-dashboard.js](../src/init-dashboard.js) and [../src/init-reference.js](../src/init-reference.js). +- [ ] Replace `$destroy()`-based teardown in [../src/helpers/selector.js](../src/helpers/selector.js), [../src/views/FileSharingPicker.js](../src/views/FileSharingPicker.js), and [../src/init-reference.js](../src/init-reference.js). +- [ ] Standardize mount and unmount behavior for widgets, selectors, and custom picker elements. + +### 1.3 Event flow cleanup + +- [ ] Remove `$root.$on(...)` and `$root.$emit(...)` patterns in [../src/helpers/selector.js](../src/helpers/selector.js), [../src/BoardSelector.vue](../src/BoardSelector.vue), [../src/CardSelector.vue](../src/CardSelector.vue), [../src/components/board/Board.vue](../src/components/board/Board.vue), [../src/components/cards/CardItem.vue](../src/components/cards/CardItem.vue), and [../src/components/cards/CardMenuEntries.vue](../src/components/cards/CardMenuEntries.vue). +- [ ] Replace root-instance messaging with explicit emits, callback props, or a dedicated external emitter. +- [ ] Document the chosen event pattern and use it consistently across entrypoints. + +### Exit criteria + +- [ ] No application code depends on `new Vue`, `Vue.extend`, `$destroy`, or root-instance event APIs. + +## Phase 2: Framework and store migration + +### 2.1 Router + +- [ ] Upgrade routing from Vue Router 3 to Vue Router 4. +- [ ] Port [../src/router.js](../src/router.js) to Vue Router 4 APIs. +- [ ] Re-verify navigation guards, redirects, and history base handling. +- [ ] Re-evaluate `vuex-router-sync` usage and replace it if necessary. + +### 2.2 Store + +- [ ] Upgrade from Vuex 3 to Vuex 4 unless a deliberate Pinia migration is approved separately. +- [ ] Replace `Vue.use(Vuex)` in [../src/store/main.js](../src/store/main.js), [../src/store/dashboard.js](../src/store/dashboard.js), and [../src/store/overview.js](../src/store/overview.js). +- [ ] Remove `Vue.set(...)` and `Vue.delete(...)` usage across store modules. +- [ ] Re-test reactivity-sensitive flows for board, stack, card, comment, and attachment updates. + +### Exit criteria + +- [ ] Router and store boot successfully on Vue 3-compatible APIs. +- [ ] Store mutations no longer rely on removed Vue 2 reactivity helpers. + +## Phase 3: Component and template compatibility cleanup + +### 3.1 Template syntax + +- [ ] Replace `.sync` patterns with `v-model:prop` or explicit `update:prop` events. +- [ ] Verify component contracts for all Nextcloud Vue components that currently use `.sync`. +- [ ] Re-test dialogs, board controls, sidebars, settings, and clone/export flows. + +### 3.2 Functional components and render helpers + +- [ ] Replace `functional: true` components in [../src/components/ActivityEntry.vue](../src/components/ActivityEntry.vue) and [../src/components/card/CommentItem.vue](../src/components/card/CommentItem.vue). +- [ ] Review render functions and slot usage for Vue 3 compatibility. + +### 3.3 Lifecycle hooks and directives + +- [ ] Rename component lifecycle hooks such as `beforeDestroy` and `destroyed`. +- [ ] Port directive hooks in [../src/directives/focus.js](../src/directives/focus.js) to Vue 3 hook names and instance access patterns. +- [ ] Re-test focus handling, keyboard shortcuts, and editor teardown behavior. + +### Exit criteria + +- [ ] Templates, directives, and lifecycle hooks are free of Vue 2-only syntax. + +## Phase 4: Entrypoint-by-entrypoint migration + +### 4.1 Small standalone pickers and helpers + +- [ ] Migrate [../src/helpers/selector.js](../src/helpers/selector.js). +- [ ] Migrate [../src/views/FileSharingPicker.js](../src/views/FileSharingPicker.js). +- [ ] Verify collaboration board and card selector flows. + +### 4.2 Dashboard widgets + +- [ ] Migrate [../src/init-dashboard.js](../src/init-dashboard.js). +- [ ] Verify upcoming, today, and tomorrow dashboard widgets. + +### 4.3 Reference widgets + +- [ ] Migrate [../src/init-reference.js](../src/init-reference.js). +- [ ] Verify board, card, comment, and custom picker render lifecycles. + +### 4.4 Main app shell + +- [ ] Migrate [../src/main.js](../src/main.js). +- [ ] Verify board loading, navigation, sidebar behavior, modal behavior, and unified search integration. + +### Exit criteria + +- [ ] Every published Deck frontend entrypoint runs on the Vue 3 stack. + +## Phase 5: Migration-build branch, if needed + +Use this phase only if the dependency audit shows that temporary compat mode will accelerate warning discovery without locking the project into a long-lived compatibility layer. + +- [ ] Create a short-lived branch or draft PR dedicated to migration-build warnings. +- [ ] Enable compat warnings globally. +- [ ] Record each warning category and map it to a code fix. +- [ ] Fix warnings by subsystem rather than silencing them. +- [ ] Remove compat mode before merge. + +### Exit criteria + +- [ ] No production code depends on the migration build. +- [ ] The final branch uses the standard Vue 3 runtime only. + +## Phase 6: Tooling, tests, and verification + +- [ ] Update build tooling, compiler packages, and test transformers for Vue 3. +- [ ] Ensure `npm run build` succeeds. +- [ ] Ensure `npm run lint` succeeds. +- [ ] Ensure `npm test` succeeds. +- [ ] Run the key user flows manually in a Nextcloud instance. +- [ ] Validate dashboard, sharing picker, collaboration picker, and reference widgets. + +## Release checklist + +- [ ] Remove temporary shims and compatibility helpers that were only needed during migration. +- [ ] Update contributor documentation if commands or tooling changed. +- [ ] Add a release note entry describing the migration impact and any known limitations. +- [ ] Confirm built assets are reproducible and committed according to project policy. + +## Open decisions + +- [ ] Confirm the supported Vue 3 version range for the surrounding Nextcloud frontend stack. +- [ ] Confirm whether `@nextcloud/vue` and related helpers can be upgraded directly in the same branch. +- [ ] Confirm whether `vuex-router-sync` remains viable or should be removed. +- [ ] Confirm replacement strategy for any Vue 2-only third-party packages. + +## Working notes + +- Prefer small PRs grouped by subsystem rather than one long-lived migration branch. +- If migration build is used, treat it as instrumentation and not as a shipping target. +- Keep this file updated when phases are split, reordered, or blocked. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 046067cd85..20e2eaa4f5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,9 @@ pages: - Nextcloud API: API-Nextcloud.md - Developer documentation: - Data structure: structure.md + - Vue 3 migration: + - Implementation plan: vue3-migration-plan.md + - Dependency audit: vue3-dependency-audit.md - Import documentation: - Implement import: implement-import.md - Class diagram: import-class-diagram.md From a0d85e69b8771ccef9503f7f56f2aa9f53df520e Mon Sep 17 00:00:00 2001 From: Theo <36564257+theoholl@users.noreply.github.com> Date: Sat, 21 Mar 2026 22:24:21 +0000 Subject: [PATCH 02/40] Refactor: Replace `vue-click-outside` with a custom directive and update migration plan Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com> --- docs/vue3-dependency-audit.md | 6 ++ docs/vue3-migration-plan.md | 2 +- src/components/board/Stack.vue | 4 -- src/components/cards/CardItem.vue | 4 -- src/components/navigation/AppNavigation.vue | 4 -- .../navigation/AppNavigationBoard.vue | 4 -- src/directives/clickOutside.js | 60 +++++++++++++++++++ src/init-reference.js | 4 +- src/main.js | 4 +- 9 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 src/directives/clickOutside.js diff --git a/docs/vue3-dependency-audit.md b/docs/vue3-dependency-audit.md index 40097c1942..6616c534c7 100644 --- a/docs/vue3-dependency-audit.md +++ b/docs/vue3-dependency-audit.md @@ -169,6 +169,12 @@ npm info repository homepage | `vue-click-outside` | isolate first | 2026-03-21 | It has a small API surface and many call sites, so replacing it early cuts migration noise across the app. | | `@nextcloud/dialogs` | isolate first | 2026-03-21 | Direct calls are spread across the app, but they already behave like helper functions and can be centralized behind local wrappers. | +## Implemented isolation seams + +- [x] `vue-click-outside` has been replaced in source with a local directive at [../src/directives/clickOutside.js](../src/directives/clickOutside.js). +- [ ] `vuex-router-sync` is still pending replacement. +- [ ] `@nextcloud/dialogs` is still pending consolidation behind local helpers. + ## Blocking conditions - [ ] No package remains in `blocked` state without an explicit mitigation plan. diff --git a/docs/vue3-migration-plan.md b/docs/vue3-migration-plan.md index d588f1b251..8f2507ed06 100644 --- a/docs/vue3-migration-plan.md +++ b/docs/vue3-migration-plan.md @@ -53,7 +53,7 @@ Use this file as the source of truth for sequencing, progress tracking, and exit ### 1.1a Build isolation seams first -- [ ] Introduce a local `clickOutside` directive and replace direct `vue-click-outside` imports. +- [x] Introduce a local `clickOutside` directive and replace direct `vue-click-outside` imports. - [ ] Replace `vuex-router-sync` with a local route-store sync helper. - [ ] Funnel direct `@nextcloud/dialogs` calls through local helper modules. - [ ] Replace deep `@nextcloud/vue/dist/...` imports with local adapters. diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 3a657a17ba..f3af5d083c 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -146,7 +146,6 @@ + + \ No newline at end of file diff --git a/src/components/card/CardSidebarTabComments.vue b/src/components/card/CardSidebarTabComments.vue index f14d927391..12627e3b61 100644 --- a/src/components/card/CardSidebarTabComments.vue +++ b/src/components/card/CardSidebarTabComments.vue @@ -23,11 +23,11 @@ :key="comment.id" :comment="comment" @doReload="loadComments" /> - +
- +
@@ -42,7 +42,7 @@ import { mapState, mapGetters } from 'vuex' import { NcAvatar } from '@nextcloud/vue' import CommentItem from './CommentItem.vue' import CommentForm from './CommentForm.vue' -import InfiniteLoading from 'vue-infinite-loading' +import InfiniteLoader from '../InfiniteLoader.vue' import { getCurrentUser } from '@nextcloud/auth' export default { @@ -51,7 +51,7 @@ export default { NcAvatar, CommentItem, CommentForm, - InfiniteLoading, + InfiniteLoader, }, props: { card: { diff --git a/src/components/search/GlobalSearchResults.vue b/src/components/search/GlobalSearchResults.vue index 877277d109..5bb3dc0ba2 100644 --- a/src/components/search/GlobalSearchResults.vue +++ b/src/components/search/GlobalSearchResults.vue @@ -22,13 +22,13 @@ :key="card.id" :standalone="true" /> - +
{{ t('deck', 'No results found') }}
- + diff --git a/src/CardSelector.vue b/src/CardSelector.vue index 7547771ed0..2fdf7f7750 100644 --- a/src/CardSelector.vue +++ b/src/CardSelector.vue @@ -102,10 +102,10 @@ export default { }, close() { - this.$root.$emit('close') + this.$emit('close') }, select() { - this.$root.$emit('select', this.selectedCard.id) + this.$emit('select', this.selectedCard.id) }, }, diff --git a/src/components/board/Board.vue b/src/components/board/Board.vue index 4ca6d3a13d..e31e347b9a 100644 --- a/src/components/board/Board.vue +++ b/src/components/board/Board.vue @@ -63,7 +63,10 @@ data-click-closes-sidebar="true" data-dragscroll-enabled class="stack-draggable-wrapper"> - +
@@ -165,9 +168,6 @@ export default { created() { // Session is created in fetchData() after loadBoardById succeeds this.fetchData() - this.$root.$on('open-card', (cardId) => { - this.localModal = cardId - }) }, beforeDestroy() { this.session?.close() @@ -213,6 +213,10 @@ export default { this.newStackTitle = '' }, + openCard(cardId) { + this.localModal = cardId + }, + onMouseDown(event) { this.startMouseDrag(event) }, diff --git a/src/components/board/Stack.vue b/src/components/board/Stack.vue index 7ffd2e39ce..3719b3e460 100644 --- a/src/components/board/Stack.vue +++ b/src/components/board/Stack.vue @@ -114,7 +114,10 @@ - + @@ -158,6 +161,7 @@ import CardItem from '../cards/CardItem.vue' export default { name: 'Stack', + emits: ['open-card'], components: { NcActions, NcActionButton, @@ -277,6 +281,9 @@ export default { return this.cardsByStack[index] } }, + openCard(cardId) { + this.$emit('open-card', cardId) + }, toggleDoneColumn() { this.$store.dispatch('setDoneStack', { stackId: this.stack.id, diff --git a/src/components/cards/CardItem.vue b/src/components/cards/CardItem.vue index 01f0b775c3..d175d70d05 100644 --- a/src/components/cards/CardItem.vue +++ b/src/components/cards/CardItem.vue @@ -43,7 +43,8 @@ + @edit-title="triggerEditTitle" + @open-card="openCardFromMenu" />
@@ -59,7 +60,8 @@ + @edit-title="triggerEditTitle" + @open-card="openCardFromMenu" />
+ @edit-title="triggerEditTitle" + @open-card="openCardFromMenu" />
@@ -96,6 +99,7 @@ const TITLE_EDITING_STATE = { export default { name: 'CardItem', + emits: ['open-card'], components: { CardBadges, AttachmentDragAndDrop, CardMenu, CardCover, DueDate }, mixins: [Color, labelStyle], props: { @@ -210,20 +214,29 @@ export default { card.focus() }, openCard(event) { - if (event.target.tagName.toLowerCase() === 'a') { + if (event?.target?.tagName?.toLowerCase() === 'a') { return } if (this.dragging || this.hasSelection()) { - return + return Promise.resolve() } const boardId = this.card && this.card.boardId ? this.card.boardId : (this.$route?.params.id ?? this.currentBoard.id) if (this.$router) { - this.$router.push({ name: 'card', params: { id: boardId, cardId: this.card.id } }).catch(() => {}) + return this.$router.push({ name: 'card', params: { id: boardId, cardId: this.card.id } }).catch(() => {}) + } + + this.$emit('open-card', this.card.id) + return Promise.resolve() + }, + openCardFromMenu(cardId) { + if (this.$router) { + const boardId = this.card && this.card.boardId ? this.card.boardId : (this.$route?.params.id ?? this.currentBoard.id) + this.$router.push({ name: 'card', params: { id: boardId, cardId } }).catch(() => {}) return } - this.$root.$emit('open-card', this.card.id) + this.$emit('open-card', cardId) }, triggerEditTitle() { this.editingTitle = TITLE_EDITING_STATE.PENDING diff --git a/src/components/cards/CardMenu.vue b/src/components/cards/CardMenu.vue index 75f4aa6e22..6e806bf9d1 100644 --- a/src/components/cards/CardMenu.vue +++ b/src/components/cards/CardMenu.vue @@ -14,7 +14,9 @@ - +
@@ -32,12 +34,15 @@ export default { default: null, }, }, - emits: ['edit-title'], + emits: ['edit-title', 'open-card'], methods: { openLink() { window.open(this.card?.referenceData?.openGraphObject?.link) return false }, + openCard(cardId) { + this.$emit('open-card', cardId) + }, editTitle(id) { this.$emit('edit-title', id) }, diff --git a/src/components/cards/CardMenuEntries.vue b/src/components/cards/CardMenuEntries.vue index 66a8eaa36f..5d1292afe7 100644 --- a/src/components/cards/CardMenuEntries.vue +++ b/src/components/cards/CardMenuEntries.vue @@ -86,7 +86,7 @@ export default { default: false, }, }, - emits: ['edit-title'], + emits: ['edit-title', 'open-card'], data() { return { modalShow: false, @@ -145,7 +145,7 @@ export default { return } - this.$root.$emit('open-card', this.card.id) + this.$emit('open-card', this.card.id) }, editTitle() { this.$emit('edit-title', this.card.id) diff --git a/src/helpers/selector.js b/src/helpers/selector.js index a5a31df706..db39c91512 100644 --- a/src/helpers/selector.js +++ b/src/helpers/selector.js @@ -3,23 +3,41 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ import Vue from 'vue' +import { appendMountTarget, mountComponent } from '../lib/mountComponent.js' + +const buildSelector = (selector, options = {}) => { + const { + props = {}, + resolveEvent = 'select', + rejectEvents = ['close'], + rejectMessage = 'Selection canceled', + } = options -const buildSelector = (selector, propsData = {}) => { return new Promise((resolve, reject) => { - const container = document.createElement('div') - document.getElementById('body-user').append(container) - const ComponentVM = new Vue({ - render: (h) => h(selector, propsData), - }).$mount(container) - ComponentVM.$root.$on('close', () => { - ComponentVM.$el.remove() - ComponentVM.$destroy() - reject(new Error('Selection canceled')) + const container = appendMountTarget() + let mountedComponent = null + const cleanup = () => { + mountedComponent?.destroy({ removeElement: true }) + } + + const on = { + [resolveEvent]: (value) => { + cleanup() + resolve(value) + }, + } + + rejectEvents.forEach((eventName) => { + on[eventName] = () => { + cleanup() + reject(new Error(rejectMessage)) + } }) - ComponentVM.$root.$on('select', (id) => { - ComponentVM.$el.remove() - ComponentVM.$destroy() - resolve(id) + + mountedComponent = mountComponent(Vue, selector, { + target: container, + props, + on, }) }) } diff --git a/src/init-dashboard.js b/src/init-dashboard.js index 7abf2e4034..7e30927edd 100644 --- a/src/init-dashboard.js +++ b/src/init-dashboard.js @@ -6,6 +6,7 @@ import './css/dashboard.scss' import './shared-init.js' +import { mountComponent } from './lib/mountComponent.js' const debug = process.env.NODE_ENV !== 'production' @@ -44,33 +45,27 @@ document.addEventListener('DOMContentLoaded', () => { const { Vue, store } = await getAsyncImports() const { default: DashboardUpcoming } = await import('./views/DashboardUpcoming.vue') - const View = Vue.extend(DashboardUpcoming) - const vm = new View({ - propsData: {}, + return mountComponent(Vue, DashboardUpcoming, { + target: el, store, - }).$mount(el) - return vm + }).root }) OCA.Dashboard.register('deckToday', async (el) => { const { Vue, store } = await getAsyncImports() const { default: DashboardToday } = await import('./views/DashboardToday.vue') - const View = Vue.extend(DashboardToday) - const vm = new View({ - propsData: {}, + return mountComponent(Vue, DashboardToday, { + target: el, store, - }).$mount(el) - return vm + }).root }) OCA.Dashboard.register('deckTomorrow', async (el) => { const { Vue, store } = await getAsyncImports() const { default: DashboardTomorrow } = await import('./views/DashboardTomorrow.vue') - const View = Vue.extend(DashboardTomorrow) - const vm = new View({ - propsData: {}, + return mountComponent(Vue, DashboardTomorrow, { + target: el, store, - }).$mount(el) - return vm + }).root }) }) diff --git a/src/init-reference.js b/src/init-reference.js index 9358b0352c..adb475df4e 100644 --- a/src/init-reference.js +++ b/src/init-reference.js @@ -7,6 +7,7 @@ import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult import { translate, translatePlural } from '@nextcloud/l10n' import storeFactory from './store/main.js' import clickOutside from './directives/clickOutside.js' +import { mountComponent } from './lib/mountComponent.js' import './shared-init.js' @@ -23,75 +24,75 @@ const prepareVue = async (Component = null) => { el.focus() }, }) - if (!Component) { - return Vue - } - - return Vue.extend(Component) + return Vue } registerWidget('deck-card', async (el, { richObjectType, richObject, accessible }) => { const { default: CardReferenceWidget } = await import('./views/CardReferenceWidget.vue') - const Widget = await prepareVue(CardReferenceWidget) + const Vue = await prepareVue(CardReferenceWidget) // trick to change the wrapper element size, otherwise it always is 100% // which is not very nice with a simple card el.parentNode.style['max-width'] = '400px' el.parentNode.style['margin-left'] = '0' el.parentNode.style['margin-right'] = '0' - new Widget({ - propsData: { + mountComponent(Vue, CardReferenceWidget, { + target: el, + props: { richObjectType, richObject, accessible, }, - }).$mount(el) + }) }) -const boardWidgets = {} +const boardWidgets = new Map() registerWidget('deck-board', async (el, { richObjectType, richObject, accessible, interactive }) => { const { default: BoardReferenceWidget } = await import('./views/BoardReferenceWidget.vue') - const Widget = await prepareVue(BoardReferenceWidget) - boardWidgets[el] = new Widget({ + const Vue = await prepareVue(BoardReferenceWidget) + boardWidgets.set(el, mountComponent(Vue, BoardReferenceWidget, { + target: el, store: storeFactory(), - propsData: { + props: { richObjectType, richObject, accessible, interactive, }, - }).$mount(el) + })) }, (el) => { - boardWidgets[el].$destroy() - delete boardWidgets[el] + boardWidgets.get(el)?.destroy() + boardWidgets.delete(el) }) registerWidget('deck-comment', async (el, { richObjectType, richObject, accessible }) => { const { default: CommentReferenceWidget } = await import('./views/CommentReferenceWidget.vue') - const Widget = await prepareVue(CommentReferenceWidget) + const Vue = await prepareVue(CommentReferenceWidget) el.parentNode.style['max-width'] = '400px' el.parentNode.style['margin-left'] = '0' el.parentNode.style['margin-right'] = '0' - new Widget({ - propsData: { + mountComponent(Vue, CommentReferenceWidget, { + target: el, + props: { richObjectType, richObject, accessible, }, - }).$mount(el) + }) }) registerCustomPickerElement('create-new-deck-card', async (el, { providerId, accessible }) => { const { default: CreateNewCardCustomPicker } = await import('./views/CreateNewCardCustomPicker.vue') - const Element = await prepareVue(CreateNewCardCustomPicker) - const vueElement = new Element({ - propsData: { + const Vue = await prepareVue(CreateNewCardCustomPicker) + const vueElement = mountComponent(Vue, CreateNewCardCustomPicker, { + target: el, + props: { providerId, accessible, }, - }).$mount(el) - return new NcCustomPickerRenderResult(vueElement.$el, vueElement) + }) + return new NcCustomPickerRenderResult(vueElement.element, vueElement) }, (el, renderResult) => { - renderResult.object.$destroy() + renderResult.object.destroy() }, 'normal') diff --git a/src/init-talk.js b/src/init-talk.js index 8188eb4f41..404432eea2 100644 --- a/src/init-talk.js +++ b/src/init-talk.js @@ -57,6 +57,8 @@ window.addEventListener('DOMContentLoaded', () => { }) + '](' + window.location.protocol + '//' + window.location.host + generateUrl('/call/' + conversationToken) + ')', }, + resolveEvent: 'submit', + rejectEvents: ['cancel', 'close'], }) } catch (e) { console.debug('Card creation dialog was canceled') diff --git a/src/lib/mountComponent.js b/src/lib/mountComponent.js new file mode 100644 index 0000000000..b4c6526d95 --- /dev/null +++ b/src/lib/mountComponent.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export function appendMountTarget({ id = null, parent = document.getElementById('body-user') || document.body } = {}) { + const container = document.createElement('div') + if (id) { + container.id = id + } + parent.append(container) + return container +} + +export function mountComponent(Vue, Component, { + target, + props = {}, + store, + on = {}, +} = {}) { + const root = new Vue({ + store, + render: (createElement) => createElement(Component, { props, on }), + }).$mount(target) + + let destroyed = false + + return { + element: root.$el, + root, + destroy({ removeElement = false } = {}) { + if (destroyed) { + return + } + + destroyed = true + if (removeElement && root.$el?.parentNode) { + root.$el.parentNode.removeChild(root.$el) + } + root.$destroy() + }, + } +} \ No newline at end of file diff --git a/src/views/FileSharingPicker.js b/src/views/FileSharingPicker.js index 34f9608f8c..cf4a7ec55e 100644 --- a/src/views/FileSharingPicker.js +++ b/src/views/FileSharingPicker.js @@ -3,44 +3,29 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import Vue from 'vue' import { createShare } from '../services/SharingApi.js' +import { buildSelector } from '../helpers/selector.js' export default { icon: 'icon-deck', displayName: t('deck', 'Share with a Deck card'), handler: async self => { + const CardSelector = () => import('./../CardSelector.vue') + const id = await buildSelector(CardSelector, { + props: { + title: t('deck', 'Share {file} with a Deck card', { file: decodeURIComponent(self.fileInfo.name) }), + action: t('deck', 'Share'), + }, + rejectMessage: 'Canceled', + }) - return new Promise((resolve, reject) => { - const container = document.createElement('div') - container.id = 'deck-board-select' - const body = document.getElementById('body-user') - body.append(container) - const CardSelector = () => import('./../CardSelector.vue') - const ComponentVM = new Vue({ - render: (h) => h(CardSelector, { - title: t('deck', 'Share {file} with a Deck card', { file: decodeURIComponent(self.fileInfo.name) }), - action: t('deck', 'Share'), - }), - }) - ComponentVM.$mount(container) - ComponentVM.$root.$on('close', () => { - ComponentVM.$el.remove() - ComponentVM.$destroy() - reject(new Error('Canceled')) - }) - ComponentVM.$root.$on('select', async (id) => { - const result = await createShare({ - path: self.fileInfo.path + '/' + self.fileInfo.name, - shareType: 12, - shareWith: '' + id, - }) - ComponentVM.$el.remove() - ComponentVM.$destroy() - resolve(result.data.ocs.data) - }) - + const result = await createShare({ + path: self.fileInfo.path + '/' + self.fileInfo.name, + shareType: 12, + shareWith: '' + id, }) + + return result.data.ocs.data }, condition: self => { return !!OC.appswebroots.deck From 156b12d9743f000b6c95d1d8fe7e109206546521 Mon Sep 17 00:00:00 2001 From: Theo <36564257+theoholl@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:14:32 +0000 Subject: [PATCH 10/40] Refactor: Centralize Vue configuration and mounting logic with `configureDeckVue` and `mountVueRoot` utilities Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com> --- docs/vue3-migration-plan.md | 6 ++-- src/init-collections.js | 9 ++++-- src/init-dashboard.js | 9 ++++-- src/init-reference.js | 18 +++++------- src/init-talk.js | 9 ++++-- src/lib/mountComponent.js | 6 ++-- src/lib/vue.js | 58 +++++++++++++++++++++++++++++++++++++ src/main.js | 31 +++++--------------- 8 files changed, 98 insertions(+), 48 deletions(-) create mode 100644 src/lib/vue.js diff --git a/docs/vue3-migration-plan.md b/docs/vue3-migration-plan.md index 8ba8abee4a..3471e58c3b 100644 --- a/docs/vue3-migration-plan.md +++ b/docs/vue3-migration-plan.md @@ -46,9 +46,9 @@ Use this file as the source of truth for sequencing, progress tracking, and exit ### 1.1 Bootstrapping and app globals -- [ ] Replace `new Vue(...)` mounting in [../src/main.js](../src/main.js). -- [ ] Replace `Vue.prototype` usage in [../src/main.js](../src/main.js), [../src/init-collections.js](../src/init-collections.js), [../src/init-dashboard.js](../src/init-dashboard.js), [../src/init-reference.js](../src/init-reference.js), and [../src/init-talk.js](../src/init-talk.js). -- [ ] Introduce shared Vue 3 mount helpers for standalone entrypoints. +- [x] Replace `new Vue(...)` mounting in [../src/main.js](../src/main.js) with a shared root-mount helper. +- [x] Replace `Vue.prototype` usage in [../src/main.js](../src/main.js), [../src/init-collections.js](../src/init-collections.js), [../src/init-dashboard.js](../src/init-dashboard.js), [../src/init-reference.js](../src/init-reference.js), and [../src/init-talk.js](../src/init-talk.js) with shared bootstrap configuration. +- [x] Introduce shared Vue 3 mount helpers for standalone entrypoints. - [ ] Move global properties, directives, and plugins to app-level registration. ### 1.1a Build isolation seams first diff --git a/src/init-collections.js b/src/init-collections.js index 124c2befbe..9d795e0ee1 100644 --- a/src/init-collections.js +++ b/src/init-collections.js @@ -8,12 +8,15 @@ import Vue from 'vue' import './../css/collections.css' import FileSharingPicker from './views/FileSharingPicker.js' import { buildSelector } from './helpers/selector.js' +import { configureDeckVue } from './lib/vue.js' import './shared-init.js' -Vue.prototype.t = t -Vue.prototype.n = n -Vue.prototype.OC = OC +configureDeckVue(Vue, { + translate: t, + translatePlural: n, + oc: OC, +}) window.addEventListener('DOMContentLoaded', () => { if (OCA.Sharing && OCA.Sharing.ShareSearch) { diff --git a/src/init-dashboard.js b/src/init-dashboard.js index 7e30927edd..b6bdfd9d23 100644 --- a/src/init-dashboard.js +++ b/src/init-dashboard.js @@ -7,6 +7,7 @@ import './css/dashboard.scss' import './shared-init.js' import { mountComponent } from './lib/mountComponent.js' +import { configureDeckVue } from './lib/vue.js' const debug = process.env.NODE_ENV !== 'production' @@ -21,9 +22,11 @@ const getAsyncImports = async () => { const { default: Vuex } = await import('vuex') const { default: dashboard } = await import('./store/dashboard.js') - Vue.prototype.t = t - Vue.prototype.n = n - Vue.prototype.OC = OC + configureDeckVue(Vue, { + translate: t, + translatePlural: n, + oc: OC, + }) Vue.use(Vuex) const store = new Vuex.Store({ diff --git a/src/init-reference.js b/src/init-reference.js index adb475df4e..ba834c78eb 100644 --- a/src/init-reference.js +++ b/src/init-reference.js @@ -6,25 +6,21 @@ import { registerWidget, registerCustomPickerElement, NcCustomPickerRenderResult } from './lib/nextcloudVue/reference.js' import { translate, translatePlural } from '@nextcloud/l10n' import storeFactory from './store/main.js' -import clickOutside from './directives/clickOutside.js' import { mountComponent } from './lib/mountComponent.js' +import { configureDeckVue } from './lib/vue.js' import './shared-init.js' const prepareVue = async (Component = null) => { const { default: Vue } = await import('vue') - Vue.prototype.t = translate - Vue.prototype.n = translatePlural - Vue.prototype.OC = window.OC - Vue.prototype.OCA = window.OCA - Vue.directive('click-outside', clickOutside) - Vue.directive('focus', { - inserted(el) { - el.focus() - }, + return configureDeckVue(Vue, { + translate, + translatePlural, + oc: window.OC, + oca: window.OCA, + installCommonDirectives: true, }) - return Vue } registerWidget('deck-card', async (el, { richObjectType, richObject, accessible }) => { diff --git a/src/init-talk.js b/src/init-talk.js index 404432eea2..053b810997 100644 --- a/src/init-talk.js +++ b/src/init-talk.js @@ -8,13 +8,16 @@ import { generateUrl } from '@nextcloud/router' import CardCreateDialog from './CardCreateDialog.vue' import { buildSelector } from './helpers/selector.js' +import { configureDeckVue } from './lib/vue.js' import './init-collections.js' import './shared-init.js' -Vue.prototype.t = t -Vue.prototype.n = n -Vue.prototype.OC = OC +configureDeckVue(Vue, { + translate: t, + translatePlural: n, + oc: OC, +}) window.addEventListener('DOMContentLoaded', () => { if (!window.OCA?.Talk?.registerMessageAction) { diff --git a/src/lib/mountComponent.js b/src/lib/mountComponent.js index b4c6526d95..33986f6a6b 100644 --- a/src/lib/mountComponent.js +++ b/src/lib/mountComponent.js @@ -3,6 +3,8 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import { mountVueRoot } from './vue.js' + export function appendMountTarget({ id = null, parent = document.getElementById('body-user') || document.body } = {}) { const container = document.createElement('div') if (id) { @@ -18,10 +20,10 @@ export function mountComponent(Vue, Component, { store, on = {}, } = {}) { - const root = new Vue({ + const root = mountVueRoot(Vue, { store, render: (createElement) => createElement(Component, { props, on }), - }).$mount(target) + }, target) let destroyed = false diff --git a/src/lib/vue.js b/src/lib/vue.js new file mode 100644 index 0000000000..3474783cbb --- /dev/null +++ b/src/lib/vue.js @@ -0,0 +1,58 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import clickOutside from '../directives/clickOutside.js' +import focus from '../directives/focus.js' +import { showError } from '../helpers/dialogs.js' + +const configuredConstructors = new WeakSet() + +export function configureDeckVue(Vue, { + translate, + translatePlural, + oc, + oca, + installCommonDirectives = false, + installErrorHandler = false, +} = {}) { + if (translate) { + Vue.prototype.t = translate + } + + if (translatePlural) { + Vue.prototype.n = translatePlural + } + + if (oc !== undefined) { + Vue.prototype.OC = oc + } + + if (oca !== undefined) { + Vue.prototype.OCA = oca + } + + if (installCommonDirectives && !configuredConstructors.has(Vue)) { + Vue.directive('click-outside', clickOutside) + Vue.directive('focus', focus) + configuredConstructors.add(Vue) + } + + if (installErrorHandler) { + Vue.config.errorHandler = (err) => { + if (err.response && err.response.data.message) { + const errorMessage = translate?.('deck', 'Something went wrong') ?? 'Something went wrong' + showError(`${errorMessage}: ${err.response.data.status} ${err.response.data.message}`) + } + console.error(err) + } + } + + return Vue +} + +export function mountVueRoot(Vue, options, target = null) { + const root = new Vue(options) + return target ? root.$mount(target) : root.$mount() +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 4a75c9c22c..6d01b40902 100644 --- a/src/main.js +++ b/src/main.js @@ -7,12 +7,11 @@ import App from './App.vue' import router from './router.js' import storeFactory from './store/main.js' import { translate, translatePlural } from '@nextcloud/l10n' -import { showError } from './helpers/dialogs.js' import { subscribe } from '@nextcloud/event-bus' -import clickOutside from './directives/clickOutside.js' import './shared-init.js' import './models/index.js' import { initSessions } from './sessions.js' +import { configureDeckVue, mountVueRoot } from './lib/vue.js' // the server snap.js conflicts with vertical scrolling so we disable it document.body.setAttribute('data-snap-ignore', 'true') @@ -20,28 +19,14 @@ document.body.setAttribute('data-snap-ignore', 'true') const store = storeFactory() initSessions(store) -Vue.prototype.t = translate -Vue.prototype.n = translatePlural - -Vue.directive('click-outside', clickOutside) - -Vue.directive('focus', { - inserted(el) { - el.focus() - }, +configureDeckVue(Vue, { + translate, + translatePlural, + installCommonDirectives: true, + installErrorHandler: true, }) -Vue.config.errorHandler = (err, vm, info) => { - if (err.response && err.response.data.message) { - const errorMessage = t('deck', 'Something went wrong') - showError(`${errorMessage}: ${err.response.data.status} ${err.response.data.message}`) - } - console.error(err) -} - -/* eslint-disable-next-line no-new */ -new Vue({ - el: '#content', +mountVueRoot(Vue, { // eslint-disable-next-line vue/match-component-file-name name: 'Deck', router, @@ -76,7 +61,7 @@ new Vue({ }, }, render: h => h(App), -}) +}, '#content') if (!window.OCA.Deck) { window.OCA.Deck = {} From 34794432dfe3864e4591b4f64d15e6aea18238d5 Mon Sep 17 00:00:00 2001 From: Theo <36564257+theoholl@users.noreply.github.com> Date: Sun, 22 Mar 2026 09:23:18 +0000 Subject: [PATCH 11/40] Refactor: Update component bindings to use `v-model` and `@update` syntax for improved reactivity Signed-off-by: Theo <36564257+theoholl@users.noreply.github.com> --- docs/vue3-migration-plan.md | 2 +- src/CardMoveDialog.vue | 12 ++++++++++-- src/components/Controls.vue | 10 ++++++++-- src/components/DeckAppSettings.vue | 7 +++++-- src/components/InfiniteLoader.vue | 8 +++++++- src/components/KeyboardShortcuts.vue | 10 ++++++++-- src/components/board/Board.vue | 11 +++++++++-- src/components/card/CardSidebar.vue | 3 ++- src/components/card/Description.vue | 8 +++++++- src/components/navigation/AppNavigation.vue | 3 ++- src/components/navigation/AppNavigationAddBoard.vue | 3 ++- src/components/navigation/AppNavigationBoard.vue | 3 ++- src/components/navigation/BoardCloneModal.vue | 12 ++++++------ src/components/navigation/BoardExportModal.vue | 6 ++++-- src/directives/focus.js | 10 +++++++++- src/main.js | 8 +++++++- 16 files changed, 89 insertions(+), 27 deletions(-) diff --git a/docs/vue3-migration-plan.md b/docs/vue3-migration-plan.md index 3471e58c3b..45423ed82f 100644 --- a/docs/vue3-migration-plan.md +++ b/docs/vue3-migration-plan.md @@ -102,7 +102,7 @@ Use this file as the source of truth for sequencing, progress tracking, and exit ### 3.1 Template syntax -- [ ] Replace `.sync` patterns with `v-model:prop` or explicit `update:prop` events. +- [x] Replace `.sync` patterns with `v-model:prop` or explicit `update:prop` events. - [ ] Verify component contracts for all Nextcloud Vue components that currently use `.sync`. - [ ] Re-test dialogs, board controls, sidebars, settings, and clone/export flows. diff --git a/src/CardMoveDialog.vue b/src/CardMoveDialog.vue index 42b8df2b3d..a408d0106b 100644 --- a/src/CardMoveDialog.vue +++ b/src/CardMoveDialog.vue @@ -3,7 +3,9 @@ - SPDX-License-Identifier: AGPL-3.0-or-later --> -