From 24628de8b8aedc244f10a142e03b06b23834803a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Wed, 20 May 2026 22:58:10 +0200 Subject: [PATCH 1/3] feat(resourceEditor): typed extension type with route + embed hosts Introduces a new `resourceEditor` extension type that lets file viewers and editors register themselves typed against `ResourceEditorBindings` instead of the previous untyped `wrappedComponent` slot contract. The loading / saving / dirty / autosave logic moves out of `AppWrapper.vue` into a `useResourceEditor` composable; the route-mounted host becomes `ResourceEditorRouteHost`. A lightweight `ResourceEditorHost` exists for embed use-cases (still route-bound today, made fully functional in a follow-up). AppWrapper and AppWrapperRoute remain as @deprecated shims that synthesize a ResourceEditorExtension from the legacy props so web-app- external and the out-of-repo web-extensions (codemirror, tiptap, json-viewer, draw-io) keep working untouched. Apps migrated to the new path: web-app-pdf-viewer, web-app-preview, web-app-text-editor, web-app-epub-reader. web-app-external stays on the legacy shim by design (AppProviderService doesn't fit cleanly into the bindings contract). --- packages/web-app-epub-reader/src/index.ts | 44 +- packages/web-app-pdf-viewer/src/App.vue | 23 +- packages/web-app-pdf-viewer/src/index.ts | 37 +- packages/web-app-preview/src/index.ts | 42 +- packages/web-app-text-editor/src/index.ts | 49 +- .../components/AppTemplates/AppWrapper.vue | 772 ++---------------- .../AppTemplates/AppWrapperRoute.ts | 6 + .../AppTemplates/ResourceEditorHost.vue | 99 +++ .../AppTemplates/ResourceEditorRouteHost.vue | 306 +++++++ .../src/components/AppTemplates/index.ts | 3 + .../AppTemplates/resourceEditorRoute.ts | 39 + packages/web-pkg/src/composables/index.ts | 1 + .../piniaStores/extensionRegistry/types.ts | 82 +- .../src/composables/resourceEditor/index.ts | 1 + .../resourceEditor/useResourceEditor.ts | 544 ++++++++++++ .../AppTemplates/ResourceEditorHost.spec.ts | 104 +++ .../ResourceEditorRouteHost.spec.ts | 117 +++ .../AppTemplates/resourceEditorRoute.spec.ts | 55 ++ .../resourceEditor/useResourceEditor.spec.ts | 269 ++++++ 19 files changed, 1757 insertions(+), 836 deletions(-) create mode 100644 packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue create mode 100644 packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue create mode 100644 packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts create mode 100644 packages/web-pkg/src/composables/resourceEditor/index.ts create mode 100644 packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts create mode 100644 packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts create mode 100644 packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts create mode 100644 packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts create mode 100644 packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts diff --git a/packages/web-app-epub-reader/src/index.ts b/packages/web-app-epub-reader/src/index.ts index af1f625308..9fd5b30dbc 100644 --- a/packages/web-app-epub-reader/src/index.ts +++ b/packages/web-app-epub-reader/src/index.ts @@ -1,34 +1,27 @@ +import { computed, defineAsyncComponent } from 'vue' import { useGettext } from 'vue3-gettext' import translations from '../l10n/translations.json' -import { AppWrapperRoute, defineWebApplication } from '@opencloud-eu/web-pkg' +import { + defineWebApplication, + resourceEditorRoute, + type ResourceEditorExtension +} from '@opencloud-eu/web-pkg' export default defineWebApplication({ setup() { const { $gettext } = useGettext() - const appId = 'epub-reader' - const routes = [ - { - path: '/:driveAliasAndItem(.*)?', - component: async () => { - // lazy loading to avoid loading the epubjs package on page load - const EpubReader = (await import('./App.vue')).default - return AppWrapperRoute(EpubReader, { - applicationId: appId, - fileContentOptions: { - responseType: 'blob' - } - }) - }, - name: 'epub-reader', - meta: { - authContext: 'hybrid', - title: $gettext('Epub Reader'), - patchCleanPath: true - } - } - ] + // Lazy component so the epubjs bundle isn't loaded on every page hit. + const EpubReader = defineAsyncComponent(() => import('./App.vue')) + + const extension: ResourceEditorExtension = { + id: 'app.epub-reader', + type: 'resourceEditor', + appId, + component: EpubReader, + fileContentOptions: { responseType: 'blob' } + } return { appInfo: { @@ -38,11 +31,12 @@ export default defineWebApplication({ extensions: [ { extension: 'epub', - routeName: 'epub-reader' + routeName: appId } ] }, - routes, + routes: [resourceEditorRoute({ extension, meta: { title: $gettext('Epub Reader') } })], + extensions: computed(() => [extension]), translations } } diff --git a/packages/web-app-pdf-viewer/src/App.vue b/packages/web-app-pdf-viewer/src/App.vue index ba48108203..68f60642c1 100644 --- a/packages/web-app-pdf-viewer/src/App.vue +++ b/packages/web-app-pdf-viewer/src/App.vue @@ -2,24 +2,11 @@ - diff --git a/packages/web-app-pdf-viewer/src/index.ts b/packages/web-app-pdf-viewer/src/index.ts index 8b76d8332d..ffb5743799 100644 --- a/packages/web-app-pdf-viewer/src/index.ts +++ b/packages/web-app-pdf-viewer/src/index.ts @@ -1,31 +1,25 @@ +import { computed } from 'vue' import { useGettext } from 'vue3-gettext' import translations from '../l10n/translations.json' -import { AppWrapperRoute, defineWebApplication } from '@opencloud-eu/web-pkg' +import { + defineWebApplication, + resourceEditorRoute, + type ResourceEditorExtension +} from '@opencloud-eu/web-pkg' import PdfViewer from './App.vue' export default defineWebApplication({ setup() { const { $gettext } = useGettext() - const appId = 'pdf-viewer' - const routes = [ - { - path: '/:driveAliasAndItem(.*)?', - component: AppWrapperRoute(PdfViewer, { - applicationId: appId, - urlForResourceOptions: { - disposition: 'inline' - } - }), - name: 'pdf-viewer', - meta: { - authContext: 'hybrid', - title: $gettext('PDF Viewer'), - patchCleanPath: true - } - } - ] + const extension: ResourceEditorExtension = { + id: 'app.pdf-viewer', + type: 'resourceEditor', + appId, + component: PdfViewer, + urlForResourceOptions: { disposition: 'inline' } + } return { appInfo: { @@ -37,11 +31,12 @@ export default defineWebApplication({ extensions: [ { extension: 'pdf', - routeName: 'pdf-viewer' + routeName: appId } ] }, - routes, + routes: [resourceEditorRoute({ extension, meta: { title: $gettext('PDF Viewer') } })], + extensions: computed(() => [extension]), translations } } diff --git a/packages/web-app-preview/src/index.ts b/packages/web-app-preview/src/index.ts index 7ab58ac7ce..386450121d 100644 --- a/packages/web-app-preview/src/index.ts +++ b/packages/web-app-preview/src/index.ts @@ -1,7 +1,9 @@ +import { computed } from 'vue' import { ApplicationInformation, - AppWrapperRoute, - defineWebApplication + defineWebApplication, + resourceEditorRoute, + type ResourceEditorExtension } from '@opencloud-eu/web-pkg' import translations from '../l10n/translations.json' import * as app from './App.vue' @@ -16,23 +18,22 @@ export default defineWebApplication({ setup() { const { $gettext } = useGettext() - const routes = [ - { - path: '/:driveAliasAndItem(.*)?', - component: AppWrapperRoute(App, { - applicationId: appId, - urlForResourceOptions: { - disposition: 'inline' - } - }), - name: 'media', - meta: { - authContext: 'hybrid', - title: $gettext('Preview'), - patchCleanPath: true - } - } - ] + const extension: ResourceEditorExtension = { + id: 'app.preview', + type: 'resourceEditor', + appId, + component: App, + urlForResourceOptions: { disposition: 'inline' } + } + + // The route is registered as `media` and gets namespaced by the runtime to + // `preview-media` (applicationId + '-' + route.name), which matches the + // routeName in appInfo.extensions below. + const route = resourceEditorRoute({ + extension, + name: 'media', + meta: { title: $gettext('Preview') } + }) const routeName = 'preview-media' @@ -49,7 +50,8 @@ export default defineWebApplication({ return { appInfo, - routes, + routes: [route], + extensions: computed(() => [extension]), translations, extensionPoints: extensionPoints() } diff --git a/packages/web-app-text-editor/src/index.ts b/packages/web-app-text-editor/src/index.ts index 12ad36ad56..06f5f2b02c 100644 --- a/packages/web-app-text-editor/src/index.ts +++ b/packages/web-app-text-editor/src/index.ts @@ -5,13 +5,14 @@ import { ApplicationFileExtension, ApplicationInformation, AppMenuItemExtension, - AppWrapperRoute, defineWebApplication, + resourceEditorRoute, useOpenEmptyEditor, useSpacesStore, - useUserStore + useUserStore, + type ResourceEditorExtension } from '@opencloud-eu/web-pkg' -import { computed } from 'vue' +import { computed, unref } from 'vue' import { urlJoin } from '@opencloud-eu/web-client' export default defineWebApplication({ @@ -89,20 +90,14 @@ export default defineWebApplication({ }, []) } - const routes = [ - { - path: '/:driveAliasAndItem(.*)?', - component: AppWrapperRoute(TextEditor, { - applicationId: appId - }), - name: 'text-editor', - meta: { - authContext: 'hybrid', - title: $gettext('Text Editor'), - patchCleanPath: true - } - } - ] + const appFileExtensions = fileExtensions() + + const extension: ResourceEditorExtension = { + id: 'app.text-editor', + type: 'resourceEditor', + appId, + component: TextEditor + } const appInfo: ApplicationInformation = { name: $gettext('Text Editor'), @@ -113,14 +108,12 @@ export default defineWebApplication({ meta: { fileSizeLimit: 2000000 }, - extensions: fileExtensions().map((extensionItem) => { - return { - extension: extensionItem.extension, - ...(Object.prototype.hasOwnProperty.call(extensionItem, 'newFileMenu') && { - newFileMenu: extensionItem.newFileMenu - }) - } - }) + extensions: appFileExtensions.map((extensionItem) => ({ + extension: extensionItem.extension, + ...(Object.prototype.hasOwnProperty.call(extensionItem, 'newFileMenu') && { + newFileMenu: extensionItem.newFileMenu + }) + })) } const menuItems = computed(() => { @@ -141,11 +134,13 @@ export default defineWebApplication({ return items }) + const extensions = computed(() => [...unref(menuItems), extension]) + return { appInfo, - routes, + routes: [resourceEditorRoute({ extension, meta: { title: $gettext('Text Editor') } })], translations, - extensions: menuItems + extensions } } }) diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 88d757eca1..6158b41aed 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -1,89 +1,25 @@ - closeApp() + diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts index ee6501ce0c..80091a2f63 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts @@ -4,6 +4,12 @@ import { AppWrapperSlotArgs } from './types' import { FileContentOptions, UrlForResourceOptions } from '../../composables' import { Resource } from '@opencloud-eu/web-client' +/** + * @deprecated Prefer {@link resourceEditorRoute} together with a typed + * `resourceEditor` extension. AppWrapperRoute still works (it delegates to the + * legacy {@link AppWrapper} shim which synthesises a ResourceEditorExtension), + * but new apps and migrations should use the new API directly. + */ export function AppWrapperRoute( fileEditor: ReturnType, options: { diff --git a/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue new file mode 100644 index 0000000000..5e602857dd --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue @@ -0,0 +1,99 @@ + + + diff --git a/packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue new file mode 100644 index 0000000000..77c4ec7a96 --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue @@ -0,0 +1,306 @@ + + + diff --git a/packages/web-pkg/src/components/AppTemplates/index.ts b/packages/web-pkg/src/components/AppTemplates/index.ts index cfd23625c2..38ba65f41d 100644 --- a/packages/web-pkg/src/components/AppTemplates/index.ts +++ b/packages/web-pkg/src/components/AppTemplates/index.ts @@ -1,3 +1,6 @@ export { default as AppWrapper } from './AppWrapper.vue' export * from './AppWrapperRoute' +export { default as ResourceEditorHost } from './ResourceEditorHost.vue' +export { default as ResourceEditorRouteHost } from './ResourceEditorRouteHost.vue' +export * from './resourceEditorRoute' export * from './types' diff --git a/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts b/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts new file mode 100644 index 0000000000..70b65a950f --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts @@ -0,0 +1,39 @@ +import { h } from 'vue' +import type { RouteRecordRaw } from 'vue-router' +import ResourceEditorRouteHost from './ResourceEditorRouteHost.vue' +import type { AuthContext, WebRouteMeta } from '../../composables/router/types' +import type { ResourceEditorExtension } from '../../composables/piniaStores' + +export interface ResourceEditorRouteOptions { + extension: ResourceEditorExtension + /** Defaults to `extension.appId`. */ + name?: string + /** Defaults to `/:driveAliasAndItem(.*)?` — the path AppWrapperRoute has used historically. */ + path?: string + /** Defaults to `'hybrid'`. */ + authContext?: AuthContext + /** Merged on top of the defaults `{authContext, patchCleanPath: true}`. */ + meta?: WebRouteMeta +} + +/** + * Build a vue-router RouteRecord that mounts a ResourceEditorExtension via + * the standalone route host. Sets the conventional defaults so apps don't + * have to repeat them. + */ +export function resourceEditorRoute(opts: ResourceEditorRouteOptions): RouteRecordRaw { + const { extension, name, path, authContext, meta } = opts + return { + name: name ?? extension.appId, + path: path ?? '/:driveAliasAndItem(.*)?', + component: { + name: `ResourceEditorRoute(${extension.appId})`, + render: () => h(ResourceEditorRouteHost, { extension }) + }, + meta: { + authContext: authContext ?? 'hybrid', + patchCleanPath: true, + ...meta + } + } +} diff --git a/packages/web-pkg/src/composables/index.ts b/packages/web-pkg/src/composables/index.ts index 5e3617df4d..d36b047949 100644 --- a/packages/web-pkg/src/composables/index.ts +++ b/packages/web-pkg/src/composables/index.ts @@ -25,6 +25,7 @@ export * from './piniaStores' export * from './previewService' export * from './requestHeaders' export * from './resources' +export * from './resourceEditor' export * from './router' export * from './scrollTo' export * from './search' diff --git a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts index 550f3e486d..c5eb1c0a74 100644 --- a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts @@ -1,10 +1,17 @@ import { Action } from '../../actions' import { SearchProvider, SideBarPanel } from '../../../components' -import { AppNavigationItem } from '../../../apps' -import { Item } from '@opencloud-eu/web-client' +import { AppNavigationItem, AppConfigObject } from '../../../apps' +import { Item, Resource, SpaceResource } from '@opencloud-eu/web-client' import { FolderView } from '../../../ui' import { Component, Slot } from 'vue' import { StringUnionOrAnyString } from '../../../utils' +import type { + AppFileHandlingResult, + AppFolderHandlingResult, + FileContext, + FileContentOptions, + UrlForResourceOptions +} from '../../appDefaults' export type ExtensionType = StringUnionOrAnyString< | 'action' @@ -16,6 +23,7 @@ export type ExtensionType = StringUnionOrAnyString< | 'sidebarPanel' | 'accountExtension' | 'floatingActionButton' + | 'resourceEditor' > export type Extension = { @@ -94,6 +102,76 @@ export interface AppMenuItemExtension extends Extension { url?: string } +/** + * The full set of bindings a resourceEditor component can receive from + * useResourceEditor. A component opts in to capabilities by declaring the + * matching prop/emit — e.g. declaring a `url` prop tells useResourceEditor + * to resolve getUrlForResource; declaring an `update:currentContent` emit + * marks the component as an editor (vs. viewer) and engages the save / + * dirty-tracking / autosave path. + * + * Every binding is typed optional here because it reflects the host's actual + * runtime contract: `resource`, `space`, `applicationConfig`, + * `currentFileContext` are guaranteed once `loading` resolves, but components + * that own their own resource loading (declaring `update:resource`) see them + * undefined initially. Components that *know* the host always provides a + * value when they render (a viewer behind the route's LoadingScreen) are + * free to declare their props strict (`url: string`) instead of `Pick<…>` — + * Vue's prop typing is covariant so it still assigns to + * {@link ResourceEditorComponent}. + */ +export interface ResourceEditorBindings { + resource?: Resource + space?: SpaceResource + isReadOnly?: boolean + applicationConfig?: AppConfigObject + currentFileContext?: FileContext + + url?: string + currentContent?: unknown + activeFiles?: Resource[] + isDirty?: boolean + isFolderLoading?: boolean + + // Method-shaped bindings (forwarded so apps don't need their own clientService wiring) + loadFolderForFileContext?: AppFolderHandlingResult['loadFolderForFileContext'] + getUrlForResource?: AppFileHandlingResult['getUrlForResource'] + revokeUrl?: AppFileHandlingResult['revokeUrl'] + + // Method-shorthand syntax (vs property arrow) is used here so TypeScript + // applies bivariant parameter checking to these callback bindings — apps + // declare emits whose handler signature varies slightly (e.g. preview's + // `register:onDeleteResourceCallback` takes a `() => Promise` rather + // than the looser `() => Promise | void` we'd otherwise enforce). + 'onUpdate:currentContent'?(value: unknown): void + 'onUpdate:resource'?(resource: Resource): void + 'onRegister:onDeleteResourceCallback'?(callback: () => Promise | void): void + 'onDelete:resource'?(): void + onSave?(): Promise | void + onClose?(): void +} + +export type ResourceEditorComponent = Component + +export interface ResourceEditorExtension extends Extension { + type: 'resourceEditor' + /** + * Stable identifier passed to useAppDefaults as applicationId. Typically + * matches the app's appInfo.id (e.g. 'text-editor', 'pdf-viewer'). For + * extensions that expose multiple editor variants within one app, use a + * dotted suffix (e.g. 'preview.image'). + */ + appId: string + + component: ResourceEditorComponent + + urlForResourceOptions?: UrlForResourceOptions + fileContentOptions?: FileContentOptions + disableAutoSave?: boolean + fileSizeLimit?: number + importResourceWithExtension?: (resource: Resource) => string | null +} + export type ExtensionPoint = { id: string extensionType: ExtensionType diff --git a/packages/web-pkg/src/composables/resourceEditor/index.ts b/packages/web-pkg/src/composables/resourceEditor/index.ts new file mode 100644 index 0000000000..8792a3ee01 --- /dev/null +++ b/packages/web-pkg/src/composables/resourceEditor/index.ts @@ -0,0 +1 @@ +export * from './useResourceEditor' diff --git a/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts new file mode 100644 index 0000000000..24e300f813 --- /dev/null +++ b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts @@ -0,0 +1,544 @@ +import { + Ref, + MaybeRefOrGetter, + computed, + onBeforeUnmount, + onMounted, + ref, + toRef, + unref, + watch +} from 'vue' +import { DateTime } from 'luxon' +import { useTask } from 'vue-concurrency' +import { useGettext } from 'vue3-gettext' +import { useRouter } from 'vue-router' +import { dirname } from 'path' +import toNumber from 'lodash-es/toNumber' + +import { + Resource, + SpaceResource, + buildIncomingShareResource, + call, + HttpError, + isPersonalSpaceResource, + isProjectSpaceResource, + isShareSpaceResource +} from '@opencloud-eu/web-client' +import { DavPermission } from '@opencloud-eu/web-client/webdav' + +import { useRoute, useRouteParam, useRouteQuery } from '../router' +import { useAppDefaults } from '../appDefaults' +import { useAppMeta } from '../appDefaults/useAppMeta' +import { queryItemAsString } from '../appDefaults/useAppNavigation' +import { useClientService } from '../clientService' +import { useEventBus } from '../eventBus' +import { useLoadingService } from '../loadingService' +import { useGetResourceContext } from '../resources' +import { useSelectedResources } from '../selection' +import { + useAppsStore, + useConfigStore, + useMessages, + useModals, + useResourcesStore, + useSharesStore, + useSpacesStore, + type ResourceEditorExtension +} from '../piniaStores' +import { formatFileSize, getSharedDriveItem } from '../../helpers' + +export interface UseResourceEditorOptions { + /** + * Reactive accepted form (ref / getter / plain) to stay consistent with the + * other extension-registry-driven composables. **Two fields are snapshot + * once at construction**, however: `extension.appId` (passed to + * `useAppDefaults`) and `extension.component` (the rendered Vue component). + * Swapping those at runtime would tear down half the host's wiring — if + * you need a different appId or component, mount a fresh host instead. + * Everything else (`fileSizeLimit`, `urlForResourceOptions`, + * `fileContentOptions`, `disableAutoSave`, `importResourceWithExtension`) + * is read reactively from `extension`. + */ + extension: MaybeRefOrGetter +} + +/** + * Centralizes the resource-loading, content-loading, and save logic that used + * to live inside `AppWrapper.vue`. Consumed by `ResourceEditorRouteHost.vue` + * (the standalone route mount) — embed-mode support (explicit resource without + * a matching route) is planned as a follow-up step; today this composable + * assumes it is called from a route-bound component. + * + * Capability detection (load url? load content? autosave? …) is driven off the + * registered `extension.component`'s declared props/emits, mirroring the + * pre-refactor heuristic but now typed against `ResourceEditorBindings`. + */ +export function useResourceEditor(options: UseResourceEditorOptions) { + const extensionRef = toRef(options.extension) + // Snapshot the identity fields once — see UseResourceEditorOptions JSDoc. + const appId = unref(extensionRef).appId + const component = unref(extensionRef).component + + const { $gettext, current: currentLanguage } = useGettext() + const router = useRouter() + const currentRoute = useRoute() + const clientService = useClientService() + const loadingService = useLoadingService() + const { getResourceContext } = useGetResourceContext() + const { selectedResources } = useSelectedResources() + const { dispatchModal } = useModals() + const { showMessage, showErrorMessage } = useMessages() + const appsStore = useAppsStore() + const spacesStore = useSpacesStore() + const configStore = useConfigStore() + const resourcesStore = useResourcesStore() + const sharesStore = useSharesStore() + const eventBus = useEventBus() + + // The component's props/emits are static for the lifetime of the host — + // they're snapshot once. Editor vs viewer is decided at runtime by whether + // the component declared `update:currentContent`; components that own their + // own resource loading (preview, etc.) declare `update:resource` instead. + const componentSpec = component as { + props?: Record | string[] + emits?: string[] | Record + } + + const hasProp = (name: string): boolean => { + const props = componentSpec.props ?? {} + if (Array.isArray(props)) return props.includes(name) + return Object.prototype.hasOwnProperty.call(props, name) + } + + const hasEmit = (name: string): boolean => { + const emits = componentSpec.emits ?? [] + if (Array.isArray(emits)) return emits.includes(name) + return Object.prototype.hasOwnProperty.call(emits, name) + } + + const isEditor = computed(() => hasEmit('update:currentContent')) + const noResourceLoading = computed(() => hasEmit('update:resource')) + + const { + applicationConfig, + closeApp, + currentFileContext, + getFileContents, + getFileInfo, + getUrlForResource, + putFileContents, + replaceInvalidFileRoute, + revokeUrl, + activeFiles, + loadFolderForFileContext, + isFolderLoading + } = useAppDefaults({ + applicationId: appId + }) + + const { applicationMeta } = useAppMeta({ + applicationId: appId, + appsStore + }) + + const fileSizeLimit = computed( + () => unref(extensionRef).fileSizeLimit ?? unref(applicationMeta).meta?.fileSizeLimit + ) + + const resource = ref() as Ref + const space = ref() as Ref + const currentETag = ref('') + const url = ref('') + const loading = ref(!unref(noResourceLoading)) + const loadingError = ref(null) + const isReadOnly = ref(false) + const serverContent = ref() + const currentContent = ref() + const deleteResourceCallback = ref<(() => void) | null>(null) + let deleteResourceEventToken = '' + + const isDirty = computed(() => unref(currentContent) !== unref(serverContent)) + + const preventUnload = (e: Event) => { + e.preventDefault() + } + + watch(isDirty, (dirty) => { + if (dirty) { + window.addEventListener('beforeunload', preventUnload) + } else { + window.removeEventListener('beforeunload', preventUnload) + } + }) + + const driveAliasAndItem = useRouteParam('driveAliasAndItem') + const fileIdQueryItem = useRouteQuery('fileId') + const fileId = computed(() => queryItemAsString(unref(fileIdQueryItem))) + + // When the user opens a file via fileId (e.g. from search results) without a + // resolved driveAliasAndItem, we need to look up the drive context first and + // push a clean route. Once that lands the watcher re-runs with both bits set. + const addMissingDriveAliasAndItem = async () => { + const id = unref(fileId) + const { space: ctxSpace, path } = await getResourceContext(id) + const dai = ctxSpace.getDriveAliasAndItem({ path } as Resource) + + if (isPersonalSpaceResource(ctxSpace)) { + return router.push({ + params: { + ...unref(currentRoute).params, + driveAliasAndItem: dai + }, + query: { + ...unref(currentRoute).query, + fileId: id, + contextRouteName: 'files-spaces-generic', + contextRouteParams: { driveAliasAndItem: dirname(dai) } as any + } + }) + } + + return router.push({ + params: { + ...unref(currentRoute).params, + driveAliasAndItem: dai + }, + query: { + ...unref(currentRoute).query, + fileId: id, + contextRouteName: path === '/' ? 'files-shares-with-me' : 'files-spaces-generic', + ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }), + contextRouteParams: { + driveAliasAndItem: dirname(dai) + } as any, + contextRouteQuery: { + ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }) + } as any + } + }) + } + + const loadResourceTask = useTask(function* (signal) { + try { + if (!unref(driveAliasAndItem)) { + yield addMissingDriveAliasAndItem() + } + space.value = unref(unref(currentFileContext).space) + const fileInfo = yield getFileInfo(unref(currentFileContext), { signal }) + resource.value = fileInfo + + if (isShareSpaceResource(unref(space))) { + // FIXME: As soon the backend exposes oc-remote-id via webdav, remove the assignment below + unref(resource).remoteItemId = unref(space).id + + if (unref(resource).id === unref(resource).remoteItemId) { + const sharedDriveItem = yield* call( + getSharedDriveItem({ + graphClient: clientService.graphAuthenticated, + spacesStore, + space: unref(space) + }) + ) + + if (sharedDriveItem) { + resource.value = { + ...fileInfo, + ...buildIncomingShareResource({ + graphRoles: sharesStore.graphRoles, + driveItem: sharedDriveItem, + serverUrl: configStore.serverUrl + }), + tags: fileInfo.tags // tags are always [] in Graph API, hence take them from webdav + } + } + } + } + resourcesStore.initResourceList({ currentFolder: null, resources: [unref(resource)] }) + selectedResources.value = [unref(resource)] + } catch (e) { + console.error(e) + loadingError.value = e as Error + loading.value = false + } + }).restartable() + + const loadFileTask = useTask(function* (signal) { + try { + const importExt = unref(extensionRef).importResourceWithExtension + const newExtension = importExt ? importExt(unref(resource)) : null + if (newExtension) { + const timestamp = DateTime.local().toFormat('yyyyMMddHHmmss') + const targetPath = `${unref(resource).name}_${timestamp}.${newExtension}` + if ( + !(yield clientService.webdav.copyFiles( + unref(space), + unref(resource), + unref(space), + { path: targetPath }, + { signal } + )) + ) { + throw new Error($gettext('Importing failed')) + } + + resource.value = { path: targetPath } as Resource + } + + if (replaceInvalidFileRoute(currentFileContext, unref(resource))) { + return + } + + isReadOnly.value = ![DavPermission.Updateable, DavPermission.FileUpdateable].some( + (p) => (unref(resource).permissions || '').indexOf(p) > -1 + ) + + if (hasProp('currentContent')) { + const fileContentsResponse = yield* call( + getFileContents(currentFileContext, { + ...unref(extensionRef).fileContentOptions, + signal + }) + ) + serverContent.value = currentContent.value = fileContentsResponse.body + currentETag.value = fileContentsResponse.headers['OC-ETag'] + } + + if (hasProp('url')) { + url.value = yield getUrlForResource(unref(space), unref(resource), { + ...unref(extensionRef).urlForResourceOptions, + signal + }) + } + } catch (e) { + console.error(e) + loadingError.value = e as Error + } finally { + loading.value = false + } + }).restartable() + + watch( + currentFileContext, + async () => { + if (!unref(noResourceLoading)) { + await loadResourceTask.perform() + + if (unref(fileSizeLimit) && toNumber(unref(resource).size) > unref(fileSizeLimit)) { + dispatchModal({ + title: $gettext('File exceeds %{threshold}', { + threshold: formatFileSize(unref(fileSizeLimit), currentLanguage) + }), + message: $gettext( + '%{resource} exceeds the recommended size of %{threshold} for editing, and may cause performance issues.', + { + resource: unref(resource).name, + threshold: formatFileSize(unref(fileSizeLimit), currentLanguage) + } + ), + confirmText: $gettext('Continue'), + onCancel: () => { + closeApp() + }, + onConfirm: () => { + loadFileTask.perform() + } + }) + } else { + loadFileTask.perform() + } + } else { + space.value = unref(unref(currentFileContext).space) + } + }, + { immediate: true } + ) + + const errorPopup = (error: HttpError) => { + console.error(error) + showErrorMessage({ + title: $gettext('An error occurred'), + desc: error.message, + errors: [error] + }) + } + + const autosavePopup = () => { + showMessage({ title: $gettext('File autosaved') }) + } + + const saveFileTask = useTask(function* () { + const newContent = unref(currentContent) + try { + const putFileContentsResponse = yield putFileContents(currentFileContext, { + content: newContent as string, + previousEntityTag: unref(currentETag) + }) + serverContent.value = newContent + currentETag.value = putFileContentsResponse.etag + resourcesStore.upsertResource(putFileContentsResponse) + } catch (e) { + switch (e.statusCode) { + case 401: + case 403: + errorPopup(new HttpError($gettext("You're not authorized to save this file"), e.response)) + break + case 409: + case 412: + errorPopup( + new HttpError( + $gettext( + 'This file was updated outside this window. Please copy your changes or save the file under a new name (»Save As...«).' + ), + e.response + ) + ) + break + case 507: + const projectSpace = spacesStore.spaces.find( + (s) => s.id === unref(resource).storageId && isProjectSpaceResource(s) + ) + if (projectSpace) { + errorPopup( + new HttpError( + $gettext('Insufficient quota on "%{spaceName}" to save this file', { + spaceName: projectSpace.name + }), + e.response + ) + ) + break + } + errorPopup(new HttpError($gettext('Insufficient quota for saving this file'), e.response)) + break + default: + errorPopup(new HttpError('', e.response)) + } + } + }).drop() + + const save = async () => { + await saveFileTask.perform() + } + + const onDeleteResourceCallback = (deletedResources: Resource[]) => { + const currentResourceDeleted = deletedResources.find( + (deletedResource) => deletedResource.id === unref(resource)?.id + ) + + if (!currentResourceDeleted) { + return + } + + if (unref(deleteResourceCallback)) { + return unref(deleteResourceCallback)() + } + + closeApp() + } + + let autosaveIntervalId: ReturnType | null = null + + onMounted(() => { + deleteResourceEventToken = eventBus.subscribe( + 'runtime.resource.deleted', + onDeleteResourceCallback + ) + + if (resourcesStore.ancestorMetaData?.['/'] && unref(space)) { + const clearAncestorData = resourcesStore.ancestorMetaData['/'].spaceId !== unref(space).id + if (clearAncestorData) { + // clear ancestor data in case the user switched spaces (e.g. by opening a file via search results) + resourcesStore.setAncestorMetaData({}) + } + } + + if (!unref(isEditor)) { + return + } + + const editorOptions = configStore.options.editor + const disableAutoSave = unref(extensionRef).disableAutoSave + if (editorOptions.autosaveEnabled && !disableAutoSave) { + autosaveIntervalId = setInterval( + async () => { + if (isDirty.value) { + await save() + autosavePopup() + } + }, + (editorOptions.autosaveInterval || 120) * 1000 + ) + } + }) + + onBeforeUnmount(() => { + eventBus.unsubscribe('runtime.resource.deleted', deleteResourceEventToken) + + if (!loadingService.isLoading) { + window.removeEventListener('beforeunload', preventUnload) + } + + if (hasProp('url') && unref(url)) { + revokeUrl(unref(url)) + } + + if (!unref(isEditor)) { + return + } + + if (autosaveIntervalId) { + clearInterval(autosaveIntervalId) + autosaveIntervalId = null + } + }) + + // Setters bridging emits from the embedded component back into our state + const setCurrentContent = (value: unknown) => { + currentContent.value = value + } + + const setResource = (value: Resource) => { + space.value = unref(unref(currentFileContext).space) + // FIXME: As soon the backend exposes oc-remote-id via webdav, remove the assignment below + resource.value = { + ...value, + ...(isShareSpaceResource(unref(space)) && { + remoteItemId: unref(space).id + }) + } + selectedResources.value = [unref(resource)] + } + + const registerOnDeleteResourceCallback = (callback: () => void) => { + deleteResourceCallback.value = callback + } + + return { + resource, + space, + url, + currentContent, + serverContent, + currentETag, + loading, + loadingError, + isReadOnly, + isDirty, + isEditor, + applicationConfig, + currentFileContext, + activeFiles, + isFolderLoading, + save, + closeApp, + loadFolderForFileContext, + getUrlForResource, + revokeUrl, + setCurrentContent, + setResource, + registerOnDeleteResourceCallback, + deleteResourceCallback + } +} diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts new file mode 100644 index 0000000000..4bb95bb519 --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts @@ -0,0 +1,104 @@ +import { defineComponent, ref } from 'vue' +import { mock } from 'vitest-mock-extended' +import { defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' +import ResourceEditorHost from '../../../../src/components/AppTemplates/ResourceEditorHost.vue' +import { useResourceEditor } from '../../../../src/composables/resourceEditor' +import type { ResourceEditorExtension } from '../../../../src/composables/piniaStores' +import type { Resource } from '@opencloud-eu/web-client' + +vi.mock('../../../../src/composables/resourceEditor/useResourceEditor') + +type UseResourceEditorReturn = ReturnType + +const buildEditorState = ( + overrides: Partial = {} +): UseResourceEditorReturn => + ({ + resource: ref(mock({ id: 'r1', name: 'doc.pdf' })), + space: ref(undefined), + url: ref(''), + currentContent: ref(''), + serverContent: ref(''), + currentETag: ref(''), + loading: ref(false), + loadingError: ref(null), + isReadOnly: ref(false), + isDirty: ref(false), + isEditor: ref(false), + applicationConfig: ref({}), + currentFileContext: ref({}), + activeFiles: ref([]), + isFolderLoading: ref(false), + save: vi.fn(), + closeApp: vi.fn(), + loadFolderForFileContext: vi.fn(), + getUrlForResource: vi.fn(), + revokeUrl: vi.fn(), + setCurrentContent: vi.fn(), + setResource: vi.fn(), + registerOnDeleteResourceCallback: vi.fn(), + deleteResourceCallback: ref(null), + ...overrides + }) as unknown as UseResourceEditorReturn + +const buildExtension = ( + component = defineComponent({ + props: ['resource'], + template: '
' + }) +): ResourceEditorExtension => ({ + id: 'app.test', + type: 'resourceEditor', + appId: 'test-app', + component +}) + +const mountHost = ( + overrides: Partial = {}, + options: { slots?: Record; extension?: ResourceEditorExtension } = {} +) => { + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(overrides)) + return mount(ResourceEditorHost, { + props: { extension: options.extension ?? buildExtension() }, + slots: options.slots, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) +} + +describe('ResourceEditorHost', () => { + it('renders the loading partial while the composable signals loading', () => { + const wrapper = mountHost({ loading: ref(true) }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) + }) + + it('renders the error partial when loadingError is set', () => { + const wrapper = mountHost({ loadingError: ref(new Error('boom')) }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + const error = wrapper.findComponent({ name: 'ErrorScreen' }) + expect(error.exists()).toBe(true) + expect(error.props('message')).toBe('boom') + }) + + it('mounts the extension component once loading resolves and forwards the resource', () => { + const wrapper = mountHost() + const stub = wrapper.find('.editor-stub') + expect(stub.exists()).toBe(true) + expect(stub.attributes('data-resource-id')).toBe('r1') + }) + + it('lets callers override the default slot to customise rendering', () => { + const wrapper = mountHost({}, { slots: { default: '
hello
' } }) + expect(wrapper.find('.custom-slot').exists()).toBe(true) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + }) + + it('lets callers override the loading slot', () => { + const wrapper = mountHost( + { loading: ref(true) }, + { slots: { loading: '
wait
' } } + ) + expect(wrapper.find('.custom-loading').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(false) + }) +}) diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts new file mode 100644 index 0000000000..1b25bd42dd --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts @@ -0,0 +1,117 @@ +import { defineComponent, ref } from 'vue' +import { mock } from 'vitest-mock-extended' +import { defaultComponentMocks, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' +import ResourceEditorRouteHost from '../../../../src/components/AppTemplates/ResourceEditorRouteHost.vue' +import { useResourceEditor } from '../../../../src/composables/resourceEditor' +import { useExtensionRegistry } from '../../../../src/composables/piniaStores' +import type { ResourceEditorExtension } from '../../../../src/composables/piniaStores' +import type { Resource } from '@opencloud-eu/web-client' + +vi.mock('../../../../src/composables/resourceEditor/useResourceEditor') + +type UseResourceEditorReturn = ReturnType + +const buildEditorState = ( + overrides: Partial = {} +): UseResourceEditorReturn => + ({ + resource: ref(mock({ id: 'r1', name: 'doc.pdf' })), + space: ref(undefined), + url: ref(''), + currentContent: ref(''), + serverContent: ref(''), + currentETag: ref(''), + loading: ref(false), + loadingError: ref(null), + isReadOnly: ref(false), + isDirty: ref(false), + isEditor: ref(false), + applicationConfig: ref({}), + currentFileContext: ref({ fileName: 'doc.pdf' }), + activeFiles: ref([]), + isFolderLoading: ref(false), + save: vi.fn(), + closeApp: vi.fn(), + loadFolderForFileContext: vi.fn(), + getUrlForResource: vi.fn(), + revokeUrl: vi.fn(), + setCurrentContent: vi.fn(), + setResource: vi.fn(), + registerOnDeleteResourceCallback: vi.fn(), + deleteResourceCallback: ref(null), + ...overrides + }) as unknown as UseResourceEditorReturn + +const buildExtension = (): ResourceEditorExtension => ({ + id: 'app.test', + type: 'resourceEditor', + appId: 'test-app', + component: defineComponent({ template: '
' }) +}) + +const mountHost = (overrides: Partial = {}) => { + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(overrides)) + const mocks = defaultComponentMocks() + return mount(ResourceEditorRouteHost, { + props: { extension: buildExtension() }, + global: { + plugins: [ + ...defaultPlugins({ + piniaOptions: { + appsState: { + apps: { 'test-app': { id: 'test-app', name: 'Test app' } } + } + } + }) + ], + mocks, + provide: mocks, + stubs: { OcSpinner: true, FileSideBar: true, AppTopBar: true } + } + }) +} + +describe('ResourceEditorRouteHost', () => { + it('renders the loading partial while loading', () => { + const wrapper = mountHost({ loading: ref(true) }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) + }) + + it('renders the error partial when loadingError is set', () => { + const wrapper = mountHost({ loadingError: ref(new Error('nope')) }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + const err = wrapper.findComponent({ name: 'ErrorScreen' }) + expect(err.exists()).toBe(true) + expect(err.props('message')).toBe('nope') + }) + + it('mounts the extension component and the file sidebar once ready', () => { + const wrapper = mountHost() + expect(wrapper.find('.editor-stub').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'FileSideBar' }).exists()).toBe(true) + }) + + it('uses extension.appId as the main element id', () => { + const wrapper = mountHost() + expect(wrapper.find('main').attributes('id')).toBe('test-app') + }) + + it('invokes closeApp when ESC is pressed', async () => { + const closeApp = vi.fn() + const wrapper = mountHost({ closeApp }) + await wrapper.find('main').trigger('keydown.esc') + expect(closeApp).toHaveBeenCalled() + }) + + // Regression: an earlier version only unregistered the TopBar inside the + // onBeforeRouteLeave callback, leaking it on unmounts that didn't go through + // a route leave (HMR, KeepAlive flush, programmatic component swap). + it('unregisters the AppTopBar extension on unmount', () => { + const wrapper = mountHost() + const { unregisterExtensions } = useExtensionRegistry() + vi.mocked(unregisterExtensions).mockClear() + wrapper.unmount() + expect(unregisterExtensions).toHaveBeenCalledWith(['app.app-wrapper.app-top-bar']) + }) +}) diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts new file mode 100644 index 0000000000..f4a667961c --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts @@ -0,0 +1,55 @@ +import { defineComponent } from 'vue' +import { resourceEditorRoute } from '../../../../src/components/AppTemplates/resourceEditorRoute' +import type { ResourceEditorExtension } from '../../../../src/composables/piniaStores' + +const buildExtension = ( + overrides: Partial = {} +): ResourceEditorExtension => ({ + id: 'app.test', + type: 'resourceEditor', + appId: 'test-app', + component: defineComponent({ template: '
' }), + ...overrides +}) + +describe('resourceEditorRoute', () => { + it('falls back to extension.appId for the route name', () => { + const route = resourceEditorRoute({ extension: buildExtension() }) + expect(route.name).toBe('test-app') + }) + + it('uses the standard driveAliasAndItem path by default', () => { + const route = resourceEditorRoute({ extension: buildExtension() }) + expect(route.path).toBe('/:driveAliasAndItem(.*)?') + }) + + it('sets authContext=hybrid and patchCleanPath=true on meta by default', () => { + const route = resourceEditorRoute({ extension: buildExtension() }) + expect(route.meta).toMatchObject({ authContext: 'hybrid', patchCleanPath: true }) + }) + + it('lets callers override name, path, authContext and extra meta', () => { + const route = resourceEditorRoute({ + extension: buildExtension(), + name: 'custom-name', + path: '/custom', + authContext: 'anonymous', + meta: { title: 'Custom title' } + }) + expect(route.name).toBe('custom-name') + expect(route.path).toBe('/custom') + expect(route.meta).toMatchObject({ + authContext: 'anonymous', + patchCleanPath: true, + title: 'Custom title' + }) + }) + + it('produces a component that re-renders on every mount (factory-shaped)', () => { + const route = resourceEditorRoute({ extension: buildExtension() }) + // The host component is built inline — assert it carries a render fn so + // the route record is mountable. + expect(route.component).toBeDefined() + expect((route.component as { render?: unknown }).render).toBeTypeOf('function') + }) +}) diff --git a/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts b/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts new file mode 100644 index 0000000000..327e4df4a4 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts @@ -0,0 +1,269 @@ +import { defineComponent, nextTick, unref } from 'vue' +import { mock } from 'vitest-mock-extended' +import { + defaultComponentMocks, + getComposableWrapper, + useAppDefaultsMock +} from '@opencloud-eu/web-test-helpers' +import { HttpError, Resource } from '@opencloud-eu/web-client' +import { useResourceEditor } from '../../../../src/composables/resourceEditor/useResourceEditor' +import { useAppDefaults } from '../../../../src/composables/appDefaults' +import { useMessages } from '../../../../src/composables/piniaStores' +import type { + ResourceEditorComponent, + ResourceEditorExtension +} from '../../../../src/composables/piniaStores' + +vi.mock('../../../../src/composables/appDefaults', async (importOriginal) => ({ + ...(await importOriginal()), + useAppDefaults: vi.fn() +})) + +type AppDefaultsResult = ReturnType + +const httpError = (statusCode: number) => + Object.assign(new Error(`HTTP ${statusCode}`), { + statusCode, + response: { status: statusCode } as any + }) + +const buildExtension = ( + overrides: Partial = {} +): ResourceEditorExtension => { + const viewerComponent = defineComponent({ + props: { url: { type: String, required: false } }, + template: '
' + }) + return { + id: 'app.test', + type: 'resourceEditor', + appId: 'test-app', + component: viewerComponent, + ...overrides + } +} + +const componentWith = ( + emits: string[], + props: Record = { url: { type: String, required: false } } +) => + // defineComponent's typed emits don't line up with the method-shorthand + // `onUpdate:*` bindings on ResourceEditorBindings — we only care about + // the runtime props/emits introspection here, so cast away the structural + // mismatch. + defineComponent({ + emits, + props, + template: '
' + }) as unknown as ResourceEditorComponent + +const selfLoadingExtension = (overrides: Partial = {}) => + buildExtension({ component: componentWith(['update:resource']), ...overrides }) + +const editorExtension = (overrides: Partial = {}) => + buildExtension({ + component: componentWith(['update:resource', 'update:currentContent']), + ...overrides + }) + +interface BuildOptions { + extension?: ResourceEditorExtension + appDefaults?: Partial + autosaveEnabled?: boolean + autosaveInterval?: number +} + +const buildWrapper = ({ + extension = buildExtension(), + appDefaults = {}, + autosaveEnabled, + autosaveInterval +}: BuildOptions = {}) => { + vi.mocked(useAppDefaults).mockReturnValue(useAppDefaultsMock(appDefaults)) + const mocks = defaultComponentMocks() + return getComposableWrapper(() => ({ ...useResourceEditor({ extension }) }), { + mocks, + // Provide `$router`/`$route` etc. as injects too — useRouter() / useRoute() + // read them via vue's inject API, not as Options-style this.$router. + provide: mocks, + pluginOptions: { + piniaOptions: { + configState: { + options: { + editor: { autosaveEnabled, autosaveInterval } + } as any + } + } + } + }) +} + +describe('useResourceEditor', () => { + describe('editor vs viewer detection', () => { + it('flags components without `update:currentContent` as viewers', () => { + const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + expect(wrapper.vm.isEditor).toBe(false) + }) + + it('flags components that emit `update:currentContent` as editors', () => { + const wrapper = buildWrapper({ extension: editorExtension() }) + expect(wrapper.vm.isEditor).toBe(true) + }) + }) + + describe('content/resource setters', () => { + it('setCurrentContent updates the currentContent ref', () => { + const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + wrapper.vm.setCurrentContent('hello') + expect(wrapper.vm.currentContent).toBe('hello') + }) + + it('setResource updates the resource ref', () => { + const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + const next = mock({ id: 'next', name: 'next.txt' }) + wrapper.vm.setResource(next) + expect((wrapper.vm.resource as Resource | undefined)?.id).toBe('next') + }) + + it('isDirty toggles once setCurrentContent diverges from serverContent', async () => { + const wrapper = buildWrapper({ extension: editorExtension() }) + expect(wrapper.vm.isDirty).toBe(false) + wrapper.vm.setCurrentContent('changed') + await nextTick() + expect(wrapper.vm.isDirty).toBe(true) + }) + }) + + describe('delete-resource callback registration', () => { + it('stores the registered callback on the returned ref', () => { + const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + const cb = vi.fn() + wrapper.vm.registerOnDeleteResourceCallback(cb) + expect(wrapper.vm.deleteResourceCallback).toBe(cb) + }) + }) + + describe('save', () => { + it('writes currentContent via putFileContents and clears the dirty flag on success', async () => { + const putFileContents = vi.fn().mockResolvedValue({ etag: 'new-etag' } as any) + const wrapper = buildWrapper({ + extension: editorExtension(), + appDefaults: { putFileContents } + }) + wrapper.vm.setCurrentContent('payload') + await nextTick() + expect(wrapper.vm.isDirty).toBe(true) + + await wrapper.vm.save() + + expect(putFileContents).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ content: 'payload' }) + ) + expect(wrapper.vm.serverContent).toBe('payload') + expect(wrapper.vm.isDirty).toBe(false) + }) + + it('reports a conflict error on 412 / 409 without touching serverContent', async () => { + const putFileContents = vi.fn().mockRejectedValue(httpError(412)) + const wrapper = buildWrapper({ + extension: editorExtension(), + appDefaults: { putFileContents } + }) + wrapper.vm.setCurrentContent('local edits') + await nextTick() + + await wrapper.vm.save() + + expect(wrapper.vm.serverContent).toBeUndefined() + expect(wrapper.vm.isDirty).toBe(true) + const { showErrorMessage } = useMessages() + expect(showErrorMessage).toHaveBeenCalled() + const arg = vi.mocked(showErrorMessage).mock.calls[0][0] + expect(arg.errors?.[0]).toBeInstanceOf(HttpError) + }) + + it('reports an auth error on 401 / 403', async () => { + const putFileContents = vi.fn().mockRejectedValue(httpError(403)) + const wrapper = buildWrapper({ + extension: editorExtension(), + appDefaults: { putFileContents } + }) + wrapper.vm.setCurrentContent('payload') + await nextTick() + + await wrapper.vm.save() + + const { showErrorMessage } = useMessages() + expect(showErrorMessage).toHaveBeenCalled() + }) + + it('reports the no-quota error on 507', async () => { + const putFileContents = vi.fn().mockRejectedValue(httpError(507)) + const wrapper = buildWrapper({ + extension: editorExtension(), + appDefaults: { putFileContents } + }) + wrapper.vm.setCurrentContent('payload') + await nextTick() + + await wrapper.vm.save() + + const { showErrorMessage } = useMessages() + expect(showErrorMessage).toHaveBeenCalled() + }) + }) + + describe('autosave wiring', () => { + it('does not start an autosave interval for viewers (no update:currentContent emit)', () => { + const spy = vi.spyOn(global, 'setInterval') + buildWrapper({ extension: selfLoadingExtension(), autosaveEnabled: true }) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('does not start an interval when extension.disableAutoSave is true', () => { + const spy = vi.spyOn(global, 'setInterval') + buildWrapper({ + extension: editorExtension({ disableAutoSave: true }), + autosaveEnabled: true + }) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + + it('starts an interval for editors when configStore.options.editor.autosaveEnabled is true', () => { + const spy = vi.spyOn(global, 'setInterval') + buildWrapper({ extension: editorExtension(), autosaveEnabled: true }) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('does not start an interval when autosaveEnabled is false', () => { + const spy = vi.spyOn(global, 'setInterval') + buildWrapper({ extension: editorExtension(), autosaveEnabled: false }) + expect(spy).not.toHaveBeenCalled() + spy.mockRestore() + }) + }) + + describe('beforeunload listener', () => { + it('attaches a beforeunload listener once isDirty flips true, removes it once it flips back', async () => { + const add = vi.spyOn(window, 'addEventListener') + const remove = vi.spyOn(window, 'removeEventListener') + + const wrapper = buildWrapper({ extension: editorExtension() }) + wrapper.vm.setCurrentContent('typed') + await nextTick() + expect(add).toHaveBeenCalledWith('beforeunload', expect.any(Function)) + + // Roll currentContent back to the (undefined) serverContent — dirty=false again. + wrapper.vm.setCurrentContent(wrapper.vm.serverContent) + await nextTick() + expect(remove).toHaveBeenCalledWith('beforeunload', expect.any(Function)) + + add.mockRestore() + remove.mockRestore() + }) + }) +}) From 7f61723ed151adab323c9d9a54a88a3ce515dfaa Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 21 May 2026 01:36:28 +0200 Subject: [PATCH 2/3] feat(resourceEditor): introduce resourceEditor extension type Makes `ResourceEditorHost` actually usable outside a route. The composable is now resource-agnostic: it takes `resource`/`space` as refs and runs capability-driven loading plus save/dirty/autosave on top. Resource resolution is split off into `useRouteFileLoader`, which the route host wires together with `useResourceEditor` for its existing behaviour. The embed host receives `resource` + `space` as props, auto-resolves a matching `resourceEditor` extension from the registry via the new `resolveResourceEditor` helper (extension/mimeType with `family/*` glob support, optional `matches()` predicate, `hasPriority` tie-break), and also accepts an explicit `extension` / `extensionId` override. A `readOnly` prop forces preview-only mounts; the host exposes its editor state via slot bindings and `defineExpose` for parents that drive their own toolbar/modal chrome. To make external read-only control actually work, `useTextEditor.readonly` now accepts a `MaybeRefOrGetter` and tracks it via a computed. The local writable ref and its sync watcher are gone. ResourceEditorExtension regains the discriminator metadata that Phase A had stripped (`extensions`, `mimeTypes`, `matches`, `hasPriority`); the host consumes them for auto-resolution. The `useFileActions` integration keeps going through `appInfo.extensions[]` for now, collapsing that duplication is a follow-up. --- packages/web-app-epub-reader/src/index.ts | 3 +- packages/web-app-pdf-viewer/src/index.ts | 2 + packages/web-app-preview/src/index.ts | 6 +- packages/web-app-text-editor/src/App.vue | 2 +- packages/web-app-text-editor/src/index.ts | 1 + .../components/AppTemplates/AppWrapper.vue | 10 +- .../AppTemplates/AppWrapperRoute.ts | 7 +- .../AppTemplates/ResourceEditorHost.vue | 112 ++++-- .../AppTemplates/ResourceEditorRouteHost.vue | 68 ++-- .../src/components/AppTemplates/index.ts | 1 + .../AppTemplates/resolveResourceEditor.ts | 32 ++ .../AppTemplates/resourceEditorRoute.ts | 11 +- .../piniaStores/extensionRegistry/types.ts | 39 +- .../src/composables/resourceEditor/index.ts | 1 + .../resourceEditor/useResourceEditor.ts | 345 +++++------------- .../resourceEditor/useRouteFileLoader.ts | 227 ++++++++++++ .../src/editor/composables/useTextEditor.ts | 4 +- packages/web-pkg/src/editor/types.ts | 6 +- .../AppTemplates/ResourceEditorHost.spec.ts | 256 ++++++++++--- .../ResourceEditorRouteHost.spec.ts | 55 ++- .../resolveResourceEditor.spec.ts | 95 +++++ .../AppTemplates/resourceEditorRoute.spec.ts | 2 +- .../resourceEditor/useResourceEditor.spec.ts | 237 +++++++++--- 23 files changed, 1057 insertions(+), 465 deletions(-) create mode 100644 packages/web-pkg/src/components/AppTemplates/resolveResourceEditor.ts create mode 100644 packages/web-pkg/src/composables/resourceEditor/useRouteFileLoader.ts create mode 100644 packages/web-pkg/tests/unit/components/AppTemplates/resolveResourceEditor.spec.ts diff --git a/packages/web-app-epub-reader/src/index.ts b/packages/web-app-epub-reader/src/index.ts index 9fd5b30dbc..ef27342abf 100644 --- a/packages/web-app-epub-reader/src/index.ts +++ b/packages/web-app-epub-reader/src/index.ts @@ -12,13 +12,14 @@ export default defineWebApplication({ const { $gettext } = useGettext() const appId = 'epub-reader' - // Lazy component so the epubjs bundle isn't loaded on every page hit. + // Defer the epubjs bundle until the editor actually renders. const EpubReader = defineAsyncComponent(() => import('./App.vue')) const extension: ResourceEditorExtension = { id: 'app.epub-reader', type: 'resourceEditor', appId, + extensions: ['epub'], component: EpubReader, fileContentOptions: { responseType: 'blob' } } diff --git a/packages/web-app-pdf-viewer/src/index.ts b/packages/web-app-pdf-viewer/src/index.ts index ffb5743799..01da3ef81e 100644 --- a/packages/web-app-pdf-viewer/src/index.ts +++ b/packages/web-app-pdf-viewer/src/index.ts @@ -17,6 +17,8 @@ export default defineWebApplication({ id: 'app.pdf-viewer', type: 'resourceEditor', appId, + extensions: ['pdf'], + mimeTypes: ['application/pdf'], component: PdfViewer, urlForResourceOptions: { disposition: 'inline' } } diff --git a/packages/web-app-preview/src/index.ts b/packages/web-app-preview/src/index.ts index 386450121d..1c06904a64 100644 --- a/packages/web-app-preview/src/index.ts +++ b/packages/web-app-preview/src/index.ts @@ -22,13 +22,13 @@ export default defineWebApplication({ id: 'app.preview', type: 'resourceEditor', appId, + mimeTypes, component: App, urlForResourceOptions: { disposition: 'inline' } } - // The route is registered as `media` and gets namespaced by the runtime to - // `preview-media` (applicationId + '-' + route.name), which matches the - // routeName in appInfo.extensions below. + // Route name `media` gets namespaced by the runtime to `preview-media` + // (applicationId-name), matching the routeName in appInfo.extensions. const route = resourceEditorRoute({ extension, name: 'media', diff --git a/packages/web-app-text-editor/src/App.vue b/packages/web-app-text-editor/src/App.vue index e448bfd293..72f45bdf48 100644 --- a/packages/web-app-text-editor/src/App.vue +++ b/packages/web-app-text-editor/src/App.vue @@ -65,7 +65,7 @@ const placeholder = computed(() => { const textEditor = useTextEditor({ contentType: unref(parsedContentType), modelValue: toRef(() => currentContent), - readonly: isReadOnly, + readonly: () => isReadOnly, placeholder: unref(placeholder), onUpdate: (content) => emit('update:currentContent', content) }) diff --git a/packages/web-app-text-editor/src/index.ts b/packages/web-app-text-editor/src/index.ts index 06f5f2b02c..b8d9f5cc85 100644 --- a/packages/web-app-text-editor/src/index.ts +++ b/packages/web-app-text-editor/src/index.ts @@ -96,6 +96,7 @@ export default defineWebApplication({ id: 'app.text-editor', type: 'resourceEditor', appId, + extensions: appFileExtensions.map((e) => e.extension), component: TextEditor } diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 6158b41aed..51daaba781 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -14,11 +14,8 @@ import { } from '../../composables' /** - * @deprecated Use {@link resourceEditorRoute} together with a typed - * `resourceEditor` extension registered via the extension registry. This - * component is now a backwards-compatibility shim that synthesises a - * ResourceEditorExtension from the legacy `applicationId` + `wrappedComponent` - * props so apps still using {@link AppWrapperRoute} keep working. + * @deprecated Backwards-compat shim. New code should use + * {@link resourceEditorRoute} with a typed `resourceEditor` extension. */ const { applicationId, @@ -41,9 +38,6 @@ if (import.meta.env.DEV) { } if (!wrappedComponent) { - // The legacy contract (AppWrapperRoute) always supplied a component. A bare - // mount without one would crash deep inside - // useResourceEditor when it probes component.props/emits — fail loud here. throw new Error( `[opencloud-eu/web-pkg] requires \`wrappedComponent\`. ` + `New apps should use \`resourceEditorRoute({ extension })\` directly.` diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts index 80091a2f63..2ad79037c7 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts @@ -4,12 +4,7 @@ import { AppWrapperSlotArgs } from './types' import { FileContentOptions, UrlForResourceOptions } from '../../composables' import { Resource } from '@opencloud-eu/web-client' -/** - * @deprecated Prefer {@link resourceEditorRoute} together with a typed - * `resourceEditor` extension. AppWrapperRoute still works (it delegates to the - * legacy {@link AppWrapper} shim which synthesises a ResourceEditorExtension), - * but new apps and migrations should use the new API directly. - */ +/** @deprecated Use {@link resourceEditorRoute} with a typed `resourceEditor` extension. */ export function AppWrapperRoute( fileEditor: ReturnType, options: { diff --git a/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue index 5e602857dd..e4c59b4811 100644 --- a/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue @@ -1,62 +1,91 @@ diff --git a/packages/web-pkg/src/components/AppTemplates/index.ts b/packages/web-pkg/src/components/AppTemplates/index.ts index 38ba65f41d..7488867866 100644 --- a/packages/web-pkg/src/components/AppTemplates/index.ts +++ b/packages/web-pkg/src/components/AppTemplates/index.ts @@ -3,4 +3,5 @@ export * from './AppWrapperRoute' export { default as ResourceEditorHost } from './ResourceEditorHost.vue' export { default as ResourceEditorRouteHost } from './ResourceEditorRouteHost.vue' export * from './resourceEditorRoute' +export * from './resolveResourceEditor' export * from './types' diff --git a/packages/web-pkg/src/components/AppTemplates/resolveResourceEditor.ts b/packages/web-pkg/src/components/AppTemplates/resolveResourceEditor.ts new file mode 100644 index 0000000000..ade619fba4 --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/resolveResourceEditor.ts @@ -0,0 +1,32 @@ +import type { Resource } from '@opencloud-eu/web-client' +import type { ResourceEditorExtension } from '../../composables/piniaStores' + +/** Matches a mime type against an exact pattern or a `family/*` glob. */ +export function matchesMimePattern(mime: string, pattern: string): boolean { + if (pattern === mime) return true + if (pattern.endsWith('/*')) { + const family = pattern.slice(0, -2) + return mime.startsWith(family + '/') + } + return false +} + +/** + * Picks the best matching `resourceEditor` extension for a resource via + * `matches()` callback, file extension or mime type. A `hasPriority` + * candidate wins ties. + */ +export function resolveResourceEditor( + resource: Resource, + candidates: ResourceEditorExtension[] +): ResourceEditorExtension | undefined { + const ext = resource.extension?.toLowerCase() + const mime = resource.mimeType?.toLowerCase() + const matches = candidates.filter((e) => { + if (e.matches?.(resource)) return true + if (ext && e.extensions?.includes(ext)) return true + if (mime && e.mimeTypes?.some((p) => matchesMimePattern(mime, p))) return true + return false + }) + return matches.find((e) => e.hasPriority) ?? matches[0] +} diff --git a/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts b/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts index 70b65a950f..afb1b1ccfa 100644 --- a/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts +++ b/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts @@ -6,20 +6,17 @@ import type { ResourceEditorExtension } from '../../composables/piniaStores' export interface ResourceEditorRouteOptions { extension: ResourceEditorExtension - /** Defaults to `extension.appId`. */ name?: string - /** Defaults to `/:driveAliasAndItem(.*)?` — the path AppWrapperRoute has used historically. */ path?: string - /** Defaults to `'hybrid'`. */ authContext?: AuthContext - /** Merged on top of the defaults `{authContext, patchCleanPath: true}`. */ meta?: WebRouteMeta } /** - * Build a vue-router RouteRecord that mounts a ResourceEditorExtension via - * the standalone route host. Sets the conventional defaults so apps don't - * have to repeat them. + * Builds a vue-router RouteRecord that mounts a ResourceEditorExtension via + * `ResourceEditorRouteHost`. Defaults: `name = extension.appId`, + * `path = '/:driveAliasAndItem(.*)?'`, `authContext = 'hybrid'`, + * `meta.patchCleanPath = true`. */ export function resourceEditorRoute(opts: ResourceEditorRouteOptions): RouteRecordRaw { const { extension, name, path, authContext, meta } = opts diff --git a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts index c5eb1c0a74..697c12223b 100644 --- a/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts +++ b/packages/web-pkg/src/composables/piniaStores/extensionRegistry/types.ts @@ -103,21 +103,14 @@ export interface AppMenuItemExtension extends Extension { } /** - * The full set of bindings a resourceEditor component can receive from - * useResourceEditor. A component opts in to capabilities by declaring the - * matching prop/emit — e.g. declaring a `url` prop tells useResourceEditor - * to resolve getUrlForResource; declaring an `update:currentContent` emit - * marks the component as an editor (vs. viewer) and engages the save / - * dirty-tracking / autosave path. + * Bindings a resourceEditor component receives from `useResourceEditor`. + * A component opts in to capabilities by declaring the matching prop/emit: + * a `url` prop triggers `getUrlForResource`, an `update:currentContent` + * emit makes it an editor and engages the save / dirty / autosave path. * - * Every binding is typed optional here because it reflects the host's actual - * runtime contract: `resource`, `space`, `applicationConfig`, - * `currentFileContext` are guaranteed once `loading` resolves, but components - * that own their own resource loading (declaring `update:resource`) see them - * undefined initially. Components that *know* the host always provides a - * value when they render (a viewer behind the route's LoadingScreen) are - * free to declare their props strict (`url: string`) instead of `Pick<…>` — - * Vue's prop typing is covariant so it still assigns to + * All bindings are optional, components are free to declare only what + * they consume. Vue's prop typing is covariant, so a component with + * strict prop types (e.g. `url: string`) still satisfies * {@link ResourceEditorComponent}. */ export interface ResourceEditorBindings { @@ -133,16 +126,13 @@ export interface ResourceEditorBindings { isDirty?: boolean isFolderLoading?: boolean - // Method-shaped bindings (forwarded so apps don't need their own clientService wiring) loadFolderForFileContext?: AppFolderHandlingResult['loadFolderForFileContext'] getUrlForResource?: AppFileHandlingResult['getUrlForResource'] revokeUrl?: AppFileHandlingResult['revokeUrl'] - // Method-shorthand syntax (vs property arrow) is used here so TypeScript - // applies bivariant parameter checking to these callback bindings — apps - // declare emits whose handler signature varies slightly (e.g. preview's - // `register:onDeleteResourceCallback` takes a `() => Promise` rather - // than the looser `() => Promise | void` we'd otherwise enforce). + // Method-shorthand syntax (not arrow) so TypeScript applies bivariant + // parameter checking, apps declare emit signatures that vary slightly + // (e.g. `register:onDeleteResourceCallback` returning `Promise`). 'onUpdate:currentContent'?(value: unknown): void 'onUpdate:resource'?(resource: Resource): void 'onRegister:onDeleteResourceCallback'?(callback: () => Promise | void): void @@ -165,6 +155,15 @@ export interface ResourceEditorExtension extends Extension { component: ResourceEditorComponent + /** File extensions (lowercase, no dot) the editor can open. */ + extensions?: string[] + /** MIME types the editor can open. Exact match or `family/*` glob. */ + mimeTypes?: string[] + /** Custom matcher for cases that extensions/mimeTypes can't express. */ + matches?: (resource: Resource) => boolean + /** Tie-breaker when multiple extensions match the same resource. */ + hasPriority?: boolean + urlForResourceOptions?: UrlForResourceOptions fileContentOptions?: FileContentOptions disableAutoSave?: boolean diff --git a/packages/web-pkg/src/composables/resourceEditor/index.ts b/packages/web-pkg/src/composables/resourceEditor/index.ts index 8792a3ee01..5d03662809 100644 --- a/packages/web-pkg/src/composables/resourceEditor/index.ts +++ b/packages/web-pkg/src/composables/resourceEditor/index.ts @@ -1 +1,2 @@ export * from './useResourceEditor' +export * from './useRouteFileLoader' diff --git a/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts index 24e300f813..b7c9b444d1 100644 --- a/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts +++ b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts @@ -1,5 +1,4 @@ import { - Ref, MaybeRefOrGetter, computed, onBeforeUnmount, @@ -9,149 +8,123 @@ import { unref, watch } from 'vue' -import { DateTime } from 'luxon' import { useTask } from 'vue-concurrency' import { useGettext } from 'vue3-gettext' -import { useRouter } from 'vue-router' -import { dirname } from 'path' import toNumber from 'lodash-es/toNumber' import { + HttpError, Resource, SpaceResource, - buildIncomingShareResource, call, - HttpError, - isPersonalSpaceResource, isProjectSpaceResource, - isShareSpaceResource + urlJoin } from '@opencloud-eu/web-client' import { DavPermission } from '@opencloud-eu/web-client/webdav' -import { useRoute, useRouteParam, useRouteQuery } from '../router' -import { useAppDefaults } from '../appDefaults' +import { useAppConfig } from '../appDefaults/useAppConfig' +import { useAppFileHandling } from '../appDefaults/useAppFileHandling' import { useAppMeta } from '../appDefaults/useAppMeta' -import { queryItemAsString } from '../appDefaults/useAppNavigation' +import type { FileContext } from '../appDefaults/types' import { useClientService } from '../clientService' import { useEventBus } from '../eventBus' import { useLoadingService } from '../loadingService' -import { useGetResourceContext } from '../resources' -import { useSelectedResources } from '../selection' import { useAppsStore, useConfigStore, useMessages, useModals, useResourcesStore, - useSharesStore, useSpacesStore, type ResourceEditorExtension } from '../piniaStores' -import { formatFileSize, getSharedDriveItem } from '../../helpers' +import { formatFileSize } from '../../helpers' export interface UseResourceEditorOptions { - /** - * Reactive accepted form (ref / getter / plain) to stay consistent with the - * other extension-registry-driven composables. **Two fields are snapshot - * once at construction**, however: `extension.appId` (passed to - * `useAppDefaults`) and `extension.component` (the rendered Vue component). - * Swapping those at runtime would tear down half the host's wiring — if - * you need a different appId or component, mount a fresh host instead. - * Everything else (`fileSizeLimit`, `urlForResourceOptions`, - * `fileContentOptions`, `disableAutoSave`, `importResourceWithExtension`) - * is read reactively from `extension`. - */ extension: MaybeRefOrGetter + resource: MaybeRefOrGetter + space: MaybeRefOrGetter + onClose?: () => void + onResourceUpdate?: (resource: Resource) => void + activeFiles?: MaybeRefOrGetter + isFolderLoading?: MaybeRefOrGetter + loadFolderForFileContext?: (ctx: FileContext) => Promise } /** - * Centralizes the resource-loading, content-loading, and save logic that used - * to live inside `AppWrapper.vue`. Consumed by `ResourceEditorRouteHost.vue` - * (the standalone route mount) — embed-mode support (explicit resource without - * a matching route) is planned as a follow-up step; today this composable - * assumes it is called from a route-bound component. - * - * Capability detection (load url? load content? autosave? …) is driven off the - * registered `extension.component`'s declared props/emits, mirroring the - * pre-refactor heuristic but now typed against `ResourceEditorBindings`. + * Resource-agnostic core: given an extension + resource + space, resolves + * capability-driven bindings (`url`, `currentContent`) by inspecting the + * component's declared props/emits, and runs save/dirty/autosave for editors. + * Route reading and resource resolution are the caller's responsibility. */ export function useResourceEditor(options: UseResourceEditorOptions) { const extensionRef = toRef(options.extension) - // Snapshot the identity fields once — see UseResourceEditorOptions JSDoc. + const resource = toRef(options.resource) + const space = toRef(options.space) + const activeFiles = toRef(options.activeFiles ?? []) + const isFolderLoading = toRef(options.isFolderLoading ?? false) + + // appId and component are snapshot at construction; swapping them would + // tear down half the host's wiring, mount a fresh host instead. const appId = unref(extensionRef).appId const component = unref(extensionRef).component const { $gettext, current: currentLanguage } = useGettext() - const router = useRouter() - const currentRoute = useRoute() const clientService = useClientService() const loadingService = useLoadingService() - const { getResourceContext } = useGetResourceContext() - const { selectedResources } = useSelectedResources() const { dispatchModal } = useModals() const { showMessage, showErrorMessage } = useMessages() const appsStore = useAppsStore() const spacesStore = useSpacesStore() const configStore = useConfigStore() const resourcesStore = useResourcesStore() - const sharesStore = useSharesStore() const eventBus = useEventBus() - // The component's props/emits are static for the lifetime of the host — - // they're snapshot once. Editor vs viewer is decided at runtime by whether - // the component declared `update:currentContent`; components that own their - // own resource loading (preview, etc.) declare `update:resource` instead. const componentSpec = component as { props?: Record | string[] emits?: string[] | Record } - const hasProp = (name: string): boolean => { const props = componentSpec.props ?? {} if (Array.isArray(props)) return props.includes(name) return Object.prototype.hasOwnProperty.call(props, name) } - const hasEmit = (name: string): boolean => { const emits = componentSpec.emits ?? [] if (Array.isArray(emits)) return emits.includes(name) return Object.prototype.hasOwnProperty.call(emits, name) } - const isEditor = computed(() => hasEmit('update:currentContent')) - const noResourceLoading = computed(() => hasEmit('update:resource')) - - const { - applicationConfig, - closeApp, - currentFileContext, - getFileContents, - getFileInfo, - getUrlForResource, - putFileContents, - replaceInvalidFileRoute, - revokeUrl, - activeFiles, - loadFolderForFileContext, - isFolderLoading - } = useAppDefaults({ - applicationId: appId - }) - const { applicationMeta } = useAppMeta({ - applicationId: appId, - appsStore - }) + const { getFileContents, getFileInfo, putFileContents, getUrlForResource, revokeUrl } = + useAppFileHandling({ clientService }) + const { applicationConfig } = useAppConfig({ appsStore, applicationId: appId }) + const { applicationMeta } = useAppMeta({ applicationId: appId, appsStore }) const fileSizeLimit = computed( () => unref(extensionRef).fileSizeLimit ?? unref(applicationMeta).meta?.fileSizeLimit ) - const resource = ref() as Ref - const space = ref() as Ref + // clientService.webdav calls only read space/item/itemId/path from + // FileContext, routing fields stay empty in the embed case. + const currentFileContext = computed(() => { + const r = unref(resource) + const s = unref(space) + return { + path: r && s ? urlJoin(s.webDavPath, r.path) : '', + space: s as SpaceResource, + item: r?.path ?? '', + itemId: r?.id ?? '', + fileName: r?.name ?? '', + driveAliasAndItem: '', + routeName: '', + routeParams: {}, + routeQuery: {} + } + }) + const currentETag = ref('') const url = ref('') - const loading = ref(!unref(noResourceLoading)) const loadingError = ref(null) const isReadOnly = ref(false) const serverContent = ref() @@ -159,12 +132,12 @@ export function useResourceEditor(options: UseResourceEditorOptions) { const deleteResourceCallback = ref<(() => void) | null>(null) let deleteResourceEventToken = '' + const loading = ref(false) const isDirty = computed(() => unref(currentContent) !== unref(serverContent)) const preventUnload = (e: Event) => { e.preventDefault() } - watch(isDirty, (dirty) => { if (dirty) { window.addEventListener('beforeunload', preventUnload) @@ -173,125 +146,23 @@ export function useResourceEditor(options: UseResourceEditorOptions) { } }) - const driveAliasAndItem = useRouteParam('driveAliasAndItem') - const fileIdQueryItem = useRouteQuery('fileId') - const fileId = computed(() => queryItemAsString(unref(fileIdQueryItem))) - - // When the user opens a file via fileId (e.g. from search results) without a - // resolved driveAliasAndItem, we need to look up the drive context first and - // push a clean route. Once that lands the watcher re-runs with both bits set. - const addMissingDriveAliasAndItem = async () => { - const id = unref(fileId) - const { space: ctxSpace, path } = await getResourceContext(id) - const dai = ctxSpace.getDriveAliasAndItem({ path } as Resource) - - if (isPersonalSpaceResource(ctxSpace)) { - return router.push({ - params: { - ...unref(currentRoute).params, - driveAliasAndItem: dai - }, - query: { - ...unref(currentRoute).query, - fileId: id, - contextRouteName: 'files-spaces-generic', - contextRouteParams: { driveAliasAndItem: dirname(dai) } as any - } - }) - } - - return router.push({ - params: { - ...unref(currentRoute).params, - driveAliasAndItem: dai - }, - query: { - ...unref(currentRoute).query, - fileId: id, - contextRouteName: path === '/' ? 'files-shares-with-me' : 'files-spaces-generic', - ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }), - contextRouteParams: { - driveAliasAndItem: dirname(dai) - } as any, - contextRouteQuery: { - ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }) - } as any - } - }) - } - - const loadResourceTask = useTask(function* (signal) { - try { - if (!unref(driveAliasAndItem)) { - yield addMissingDriveAliasAndItem() - } - space.value = unref(unref(currentFileContext).space) - const fileInfo = yield getFileInfo(unref(currentFileContext), { signal }) - resource.value = fileInfo - - if (isShareSpaceResource(unref(space))) { - // FIXME: As soon the backend exposes oc-remote-id via webdav, remove the assignment below - unref(resource).remoteItemId = unref(space).id - - if (unref(resource).id === unref(resource).remoteItemId) { - const sharedDriveItem = yield* call( - getSharedDriveItem({ - graphClient: clientService.graphAuthenticated, - spacesStore, - space: unref(space) - }) - ) - - if (sharedDriveItem) { - resource.value = { - ...fileInfo, - ...buildIncomingShareResource({ - graphRoles: sharesStore.graphRoles, - driveItem: sharedDriveItem, - serverUrl: configStore.serverUrl - }), - tags: fileInfo.tags // tags are always [] in Graph API, hence take them from webdav - } - } - } - } - resourcesStore.initResourceList({ currentFolder: null, resources: [unref(resource)] }) - selectedResources.value = [unref(resource)] - } catch (e) { - console.error(e) - loadingError.value = e as Error - loading.value = false - } - }).restartable() - const loadFileTask = useTask(function* (signal) { + const r = unref(resource) + const s = unref(space) + if (!r || !s) { + return + } try { - const importExt = unref(extensionRef).importResourceWithExtension - const newExtension = importExt ? importExt(unref(resource)) : null - if (newExtension) { - const timestamp = DateTime.local().toFormat('yyyyMMddHHmmss') - const targetPath = `${unref(resource).name}_${timestamp}.${newExtension}` - if ( - !(yield clientService.webdav.copyFiles( - unref(space), - unref(resource), - unref(space), - { path: targetPath }, - { signal } - )) - ) { - throw new Error($gettext('Importing failed')) - } - - resource.value = { path: targetPath } as Resource - } - - if (replaceInvalidFileRoute(currentFileContext, unref(resource))) { - return + loading.value = true + loadingError.value = null + // Revoke the previous blob URL so resource swaps don't leak ObjectURLs. + if (hasProp('url') && url.value) { + revokeUrl(url.value) + url.value = '' } isReadOnly.value = ![DavPermission.Updateable, DavPermission.FileUpdateable].some( - (p) => (unref(resource).permissions || '').indexOf(p) > -1 + (p) => (r.permissions || '').indexOf(p) > -1 ) if (hasProp('currentContent')) { @@ -306,7 +177,7 @@ export function useResourceEditor(options: UseResourceEditorOptions) { } if (hasProp('url')) { - url.value = yield getUrlForResource(unref(space), unref(resource), { + url.value = yield getUrlForResource(s, r, { ...unref(extensionRef).urlForResourceOptions, signal }) @@ -320,36 +191,34 @@ export function useResourceEditor(options: UseResourceEditorOptions) { }).restartable() watch( - currentFileContext, - async () => { - if (!unref(noResourceLoading)) { - await loadResourceTask.perform() - - if (unref(fileSizeLimit) && toNumber(unref(resource).size) > unref(fileSizeLimit)) { - dispatchModal({ - title: $gettext('File exceeds %{threshold}', { - threshold: formatFileSize(unref(fileSizeLimit), currentLanguage) - }), - message: $gettext( - '%{resource} exceeds the recommended size of %{threshold} for editing, and may cause performance issues.', - { - resource: unref(resource).name, - threshold: formatFileSize(unref(fileSizeLimit), currentLanguage) - } - ), - confirmText: $gettext('Continue'), - onCancel: () => { - closeApp() - }, - onConfirm: () => { - loadFileTask.perform() + [() => unref(resource), () => unref(space)], + ([r]) => { + if (!r) { + return + } + const limit = unref(fileSizeLimit) + if (limit && toNumber(r.size) > limit) { + dispatchModal({ + title: $gettext('File exceeds %{threshold}', { + threshold: formatFileSize(limit, currentLanguage) + }), + message: $gettext( + '%{resource} exceeds the recommended size of %{threshold} for editing, and may cause performance issues.', + { + resource: r.name, + threshold: formatFileSize(limit, currentLanguage) } - }) - } else { - loadFileTask.perform() - } + ), + confirmText: $gettext('Continue'), + onCancel: () => { + options.onClose?.() + }, + onConfirm: () => { + loadFileTask.perform() + } + }) } else { - space.value = unref(unref(currentFileContext).space) + loadFileTask.perform() } }, { immediate: true } @@ -363,13 +232,13 @@ export function useResourceEditor(options: UseResourceEditorOptions) { errors: [error] }) } - const autosavePopup = () => { showMessage({ title: $gettext('File autosaved') }) } const saveFileTask = useTask(function* () { const newContent = unref(currentContent) + const r = unref(resource) try { const putFileContentsResponse = yield putFileContents(currentFileContext, { content: newContent as string, @@ -397,7 +266,7 @@ export function useResourceEditor(options: UseResourceEditorOptions) { break case 507: const projectSpace = spacesStore.spaces.find( - (s) => s.id === unref(resource).storageId && isProjectSpaceResource(s) + (s) => s.id === r?.storageId && isProjectSpaceResource(s) ) if (projectSpace) { errorPopup( @@ -422,19 +291,20 @@ export function useResourceEditor(options: UseResourceEditorOptions) { await saveFileTask.perform() } + const closeApp = () => { + options.onClose?.() + } + const onDeleteResourceCallback = (deletedResources: Resource[]) => { const currentResourceDeleted = deletedResources.find( (deletedResource) => deletedResource.id === unref(resource)?.id ) - if (!currentResourceDeleted) { return } - if (unref(deleteResourceCallback)) { - return unref(deleteResourceCallback)() + return unref(deleteResourceCallback)!() } - closeApp() } @@ -446,21 +316,13 @@ export function useResourceEditor(options: UseResourceEditorOptions) { onDeleteResourceCallback ) - if (resourcesStore.ancestorMetaData?.['/'] && unref(space)) { - const clearAncestorData = resourcesStore.ancestorMetaData['/'].spaceId !== unref(space).id - if (clearAncestorData) { - // clear ancestor data in case the user switched spaces (e.g. by opening a file via search results) - resourcesStore.setAncestorMetaData({}) - } - } - if (!unref(isEditor)) { return } const editorOptions = configStore.options.editor const disableAutoSave = unref(extensionRef).disableAutoSave - if (editorOptions.autosaveEnabled && !disableAutoSave) { + if (editorOptions?.autosaveEnabled && !disableAutoSave) { autosaveIntervalId = setInterval( async () => { if (isDirty.value) { @@ -484,33 +346,18 @@ export function useResourceEditor(options: UseResourceEditorOptions) { revokeUrl(unref(url)) } - if (!unref(isEditor)) { - return - } - if (autosaveIntervalId) { clearInterval(autosaveIntervalId) autosaveIntervalId = null } }) - // Setters bridging emits from the embedded component back into our state const setCurrentContent = (value: unknown) => { currentContent.value = value } - const setResource = (value: Resource) => { - space.value = unref(unref(currentFileContext).space) - // FIXME: As soon the backend exposes oc-remote-id via webdav, remove the assignment below - resource.value = { - ...value, - ...(isShareSpaceResource(unref(space)) && { - remoteItemId: unref(space).id - }) - } - selectedResources.value = [unref(resource)] + options.onResourceUpdate?.(value) } - const registerOnDeleteResourceCallback = (callback: () => void) => { deleteResourceCallback.value = callback } @@ -533,9 +380,9 @@ export function useResourceEditor(options: UseResourceEditorOptions) { isFolderLoading, save, closeApp, - loadFolderForFileContext, getUrlForResource, revokeUrl, + loadFolderForFileContext: options.loadFolderForFileContext ?? (async () => undefined), setCurrentContent, setResource, registerOnDeleteResourceCallback, diff --git a/packages/web-pkg/src/composables/resourceEditor/useRouteFileLoader.ts b/packages/web-pkg/src/composables/resourceEditor/useRouteFileLoader.ts new file mode 100644 index 0000000000..802998fda8 --- /dev/null +++ b/packages/web-pkg/src/composables/resourceEditor/useRouteFileLoader.ts @@ -0,0 +1,227 @@ +import { Ref, computed, ref, unref, watch } from 'vue' +import { DateTime } from 'luxon' +import { useTask } from 'vue-concurrency' +import { useRouter } from 'vue-router' +import { dirname } from 'path' +import { + Resource, + SpaceResource, + buildIncomingShareResource, + call, + isPersonalSpaceResource, + isShareSpaceResource +} from '@opencloud-eu/web-client' + +import { useAppDefaults } from '../appDefaults' +import { queryItemAsString } from '../appDefaults/useAppNavigation' +import { useClientService } from '../clientService' +import { useGetResourceContext } from '../resources' +import { useRoute, useRouteParam, useRouteQuery } from '../router' +import { useSelectedResources } from '../selection' +import { useConfigStore, useResourcesStore, useSharesStore, useSpacesStore } from '../piniaStores' +import { getSharedDriveItem } from '../../helpers' + +export interface UseRouteFileLoaderOptions { + applicationId: string + /** + * When set and it returns a non-empty extension string, the resource is + * copied to a sibling file with that extension and the route is replaced + * to point at the copy (drawio-style import flows, e.g. .vsdx → .drawio). + */ + importResourceWithExtension?: (resource: Resource) => string | null +} + +/** + * Resolves the resource to view/edit from the current vue-router route: + * reads `driveAliasAndItem` / `fileId`, back-fills a clean route via + * `useGetResourceContext` when only `fileId` is present, fetches file info, + * and reconstructs an incoming-share resource via the Graph API where needed. + * Re-exports the route-bound `useAppDefaults` helpers the host needs so the + * latter can be wired into `useResourceEditor` without invoking + * `useAppDefaults` a second time. + */ +export function useRouteFileLoader({ + applicationId, + importResourceWithExtension +}: UseRouteFileLoaderOptions) { + const router = useRouter() + const currentRoute = useRoute() + const clientService = useClientService() + const { getResourceContext } = useGetResourceContext() + const { selectedResources } = useSelectedResources() + const spacesStore = useSpacesStore() + const configStore = useConfigStore() + const resourcesStore = useResourcesStore() + const sharesStore = useSharesStore() + + const appDefaults = useAppDefaults({ applicationId }) + const { + closeApp, + currentFileContext, + getFileInfo, + replaceInvalidFileRoute, + activeFiles, + loadFolderForFileContext, + isFolderLoading + } = appDefaults + + const resource = ref() as Ref + const space = ref() as Ref + const loading = ref(true) + const loadingError = ref(null) + + const driveAliasAndItem = useRouteParam('driveAliasAndItem') + const fileIdQueryItem = useRouteQuery('fileId') + const fileId = computed(() => queryItemAsString(unref(fileIdQueryItem))) + + // Search results open files via `?fileId=…` without a driveAliasAndItem. + // Resolve drive+path via Graph and push a clean route; the watcher below + // re-runs with the freshly populated param. + const addMissingDriveAliasAndItem = async () => { + const id = unref(fileId) + const { space: ctxSpace, path } = await getResourceContext(id) + const dai = ctxSpace.getDriveAliasAndItem({ path } as Resource) + + if (isPersonalSpaceResource(ctxSpace)) { + return router.push({ + params: { + ...unref(currentRoute).params, + driveAliasAndItem: dai + }, + query: { + ...unref(currentRoute).query, + fileId: id, + contextRouteName: 'files-spaces-generic', + contextRouteParams: { driveAliasAndItem: dirname(dai) } as any + } + }) + } + + return router.push({ + params: { + ...unref(currentRoute).params, + driveAliasAndItem: dai + }, + query: { + ...unref(currentRoute).query, + fileId: id, + contextRouteName: path === '/' ? 'files-shares-with-me' : 'files-spaces-generic', + ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }), + contextRouteParams: { + driveAliasAndItem: dirname(dai) + } as any, + contextRouteQuery: { + ...(isShareSpaceResource(ctxSpace) && { shareId: ctxSpace.id }) + } as any + } + }) + } + + const loadResourceTask = useTask(function* (signal) { + try { + loading.value = true + loadingError.value = null + + if (!unref(driveAliasAndItem)) { + yield addMissingDriveAliasAndItem() + } + space.value = unref(unref(currentFileContext).space) + let fileInfo: Resource = yield getFileInfo(unref(currentFileContext), { signal }) + + // webdav doesn't expose oc-remote-id on share roots; patch from the + // space and overlay Graph driveItem fields when the resource is the + // share root itself. + if (isShareSpaceResource(unref(space))) { + fileInfo.remoteItemId = unref(space)!.id + + if (fileInfo.id === fileInfo.remoteItemId) { + const sharedDriveItem = yield* call( + getSharedDriveItem({ + graphClient: clientService.graphAuthenticated, + spacesStore, + space: unref(space)! + }) + ) + + if (sharedDriveItem) { + fileInfo = { + ...fileInfo, + ...buildIncomingShareResource({ + graphRoles: sharesStore.graphRoles, + driveItem: sharedDriveItem, + serverUrl: configStore.serverUrl + }), + tags: fileInfo.tags // Graph API returns []; keep webdav tags. + } + } + } + } + + const newExtension = importResourceWithExtension?.(fileInfo) + if (newExtension) { + const timestamp = DateTime.local().toFormat('yyyyMMddHHmmss') + const targetPath = `${fileInfo.name}_${timestamp}.${newExtension}` + if ( + !(yield clientService.webdav.copyFiles( + unref(space)!, + fileInfo, + unref(space)!, + { path: targetPath }, + { signal } + )) + ) { + throw new Error('Importing failed') + } + fileInfo = { path: targetPath } as Resource + } + + if (replaceInvalidFileRoute(currentFileContext, fileInfo)) { + // The watcher will re-enter with the corrected path. + return + } + + resource.value = fileInfo + resourcesStore.initResourceList({ currentFolder: null, resources: [fileInfo] }) + selectedResources.value = [fileInfo] + + // Cross-space open-via-search: drop stale ancestor metadata. + if (resourcesStore.ancestorMetaData?.['/'] && unref(space)) { + if (resourcesStore.ancestorMetaData['/'].spaceId !== unref(space)!.id) { + resourcesStore.setAncestorMetaData({}) + } + } + } catch (e) { + console.error(e) + loadingError.value = e as Error + } finally { + loading.value = false + } + }).restartable() + + watch(currentFileContext, () => loadResourceTask.perform(), { immediate: true }) + + // Preview's photo-roll navigates by emitting `update:resource`; the loader + // owns the ref so it's also the one to update it. + const setResource = (value: Resource) => { + space.value = unref(unref(currentFileContext).space) + resource.value = { + ...value, + ...(isShareSpaceResource(unref(space)!) && { + remoteItemId: unref(space)!.id + }) + } + selectedResources.value = [resource.value as Resource] + } + + return { + resource, + space, + loading, + loadingError, + setResource, + closeApp, + activeFiles, + isFolderLoading, + loadFolderForFileContext + } +} diff --git a/packages/web-pkg/src/editor/composables/useTextEditor.ts b/packages/web-pkg/src/editor/composables/useTextEditor.ts index 4286dbd5c6..87c0db412b 100644 --- a/packages/web-pkg/src/editor/composables/useTextEditor.ts +++ b/packages/web-pkg/src/editor/composables/useTextEditor.ts @@ -1,4 +1,4 @@ -import { ref, computed, onBeforeUnmount, watch, unref, onMounted, triggerRef } from 'vue' +import { ref, computed, onBeforeUnmount, watch, unref, onMounted, toValue, triggerRef } from 'vue' import { useEditor } from '@tiptap/vue-3' import { Placeholder } from '@tiptap/extension-placeholder' import type { ShallowRef } from 'vue' @@ -14,7 +14,7 @@ export function useTextEditor(options: TextEditorOptions): TextEditorInstance { } const contentType = ref(options.contentType) - const readonly = ref(options.readonly ?? false) + const readonly = computed(() => toValue(options.readonly) ?? false) const strategy = resolveStrategy(options.contentType, state) let debounceTimer: ReturnType | null = null diff --git a/packages/web-pkg/src/editor/types.ts b/packages/web-pkg/src/editor/types.ts index 38c341d354..32b162380f 100644 --- a/packages/web-pkg/src/editor/types.ts +++ b/packages/web-pkg/src/editor/types.ts @@ -1,4 +1,4 @@ -import type { ShallowRef, Ref, ComputedRef } from 'vue' +import type { ShallowRef, Ref, ComputedRef, MaybeRefOrGetter } from 'vue' import { Editor } from '@tiptap/vue-3' import { EditorActionGroup } from './composables' @@ -7,7 +7,7 @@ export type ContentType = 'plain-text' | 'markdown' | 'html' | 'tiptap-json' export interface TextEditorOptions { contentType: ContentType modelValue?: Ref - readonly?: boolean + readonly?: MaybeRefOrGetter slashCommands?: boolean placeholder?: string onUpdate?: (content: string) => void @@ -23,7 +23,7 @@ export interface TextEditorInstance { state: TextEditorState editor: ShallowRef contentType: Ref - readonly: Ref + readonly: ComputedRef actionGroups(): EditorActionGroup[] getContent(): string isEmpty: ComputedRef diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts index 4bb95bb519..125c561fab 100644 --- a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts @@ -3,11 +3,30 @@ import { mock } from 'vitest-mock-extended' import { defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import ResourceEditorHost from '../../../../src/components/AppTemplates/ResourceEditorHost.vue' import { useResourceEditor } from '../../../../src/composables/resourceEditor' -import type { ResourceEditorExtension } from '../../../../src/composables/piniaStores' -import type { Resource } from '@opencloud-eu/web-client' +import { + useExtensionRegistry, + type ResourceEditorExtension +} from '../../../../src/composables/piniaStores' +import type { Resource, SpaceResource } from '@opencloud-eu/web-client' vi.mock('../../../../src/composables/resourceEditor/useResourceEditor') +vi.mock( + '../../../../src/composables/piniaStores/extensionRegistry/extensionRegistry', + async (importOriginal) => ({ + ...(await importOriginal()), + useExtensionRegistry: vi.fn(() => ({ + requestExtensions: vi.fn(() => []), + registerExtensions: vi.fn(), + unregisterExtensions: vi.fn(), + getExtensionById: vi.fn(), + registerExtensionPoints: vi.fn(), + unregisterExtensionPoints: vi.fn(), + getExtensionPoints: vi.fn() + })) + }) +) + type UseResourceEditorReturn = ReturnType const buildEditorState = ( @@ -41,64 +60,215 @@ const buildEditorState = ( ...overrides }) as unknown as UseResourceEditorReturn +const stubComponent = defineComponent({ + props: ['resource'], + template: '
' +}) + const buildExtension = ( - component = defineComponent({ - props: ['resource'], - template: '
' - }) + overrides: Partial = {} ): ResourceEditorExtension => ({ id: 'app.test', type: 'resourceEditor', appId: 'test-app', - component + component: stubComponent, + ...overrides }) -const mountHost = ( - overrides: Partial = {}, - options: { slots?: Record; extension?: ResourceEditorExtension } = {} -) => { - vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(overrides)) - return mount(ResourceEditorHost, { - props: { extension: options.extension ?? buildExtension() }, +// Plain object instead of mock() because vitest-mock-extended's +// proxy turns unset string properties into `vi.fn()`, which then break the +// `resource.mimeType?.toLowerCase()` call in `resolveResourceEditor`. +const buildResource = (overrides: Partial = {}): Resource => + ({ id: 'r1', name: 'doc.pdf', extension: 'pdf', ...overrides }) as Resource + +const buildSpace = () => mock({ id: 's1', webDavPath: '/dav/spaces/s1' }) + +interface MountOptions { + editorState?: Partial + resource?: Resource + space?: SpaceResource + extension?: ResourceEditorExtension + extensionId?: string + registryExtensions?: ResourceEditorExtension[] + slots?: Record +} + +const mountHost = (options: MountOptions = {}) => { + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(options.editorState)) + const wrapper = mount(ResourceEditorHost, { + props: { + resource: options.resource ?? buildResource(), + space: options.space ?? buildSpace(), + extension: options.extension, + extensionId: options.extensionId + }, slots: options.slots, global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } }) + // Inject registry candidates so auto-resolution can find them. + if (options.registryExtensions) { + const registry = useExtensionRegistry() + vi.mocked(registry.requestExtensions).mockReturnValue(options.registryExtensions as any) + // Force a re-render of the resolved-extension computed by triggering a + // small reactive nudge, we re-mount in tests rather than fighting Pinia + // mock laziness here. + } + return wrapper } describe('ResourceEditorHost', () => { - it('renders the loading partial while the composable signals loading', () => { - const wrapper = mountHost({ loading: ref(true) }) - expect(wrapper.find('.editor-stub').exists()).toBe(false) - expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) - }) + describe('with an explicit `extension` prop', () => { + it('mounts the extension component once loading resolves', () => { + const wrapper = mountHost({ extension: buildExtension() }) + const stub = wrapper.find('.editor-stub') + expect(stub.exists()).toBe(true) + expect(stub.attributes('data-resource-id')).toBe('r1') + }) - it('renders the error partial when loadingError is set', () => { - const wrapper = mountHost({ loadingError: ref(new Error('boom')) }) - expect(wrapper.find('.editor-stub').exists()).toBe(false) - const error = wrapper.findComponent({ name: 'ErrorScreen' }) - expect(error.exists()).toBe(true) - expect(error.props('message')).toBe('boom') - }) + it('renders the loading partial while the composable signals loading', () => { + const wrapper = mountHost({ + extension: buildExtension(), + editorState: { loading: ref(true) as any } + }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) + }) - it('mounts the extension component once loading resolves and forwards the resource', () => { - const wrapper = mountHost() - const stub = wrapper.find('.editor-stub') - expect(stub.exists()).toBe(true) - expect(stub.attributes('data-resource-id')).toBe('r1') - }) + it('renders the error partial when loadingError is set', () => { + const wrapper = mountHost({ + extension: buildExtension(), + editorState: { loadingError: ref(new Error('boom')) as any } + }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + const error = wrapper.findComponent({ name: 'ErrorScreen' }) + expect(error.exists()).toBe(true) + expect(error.props('message')).toBe('boom') + }) - it('lets callers override the default slot to customise rendering', () => { - const wrapper = mountHost({}, { slots: { default: '
hello
' } }) - expect(wrapper.find('.custom-slot').exists()).toBe(true) - expect(wrapper.find('.editor-stub').exists()).toBe(false) + it('lets callers override the default slot', () => { + const wrapper = mountHost({ + extension: buildExtension(), + slots: { default: '
hello
' } + }) + expect(wrapper.find('.custom-slot').exists()).toBe(true) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + }) + + it('lets callers override the loading slot', () => { + const wrapper = mountHost({ + extension: buildExtension(), + editorState: { loading: ref(true) as any }, + slots: { loading: '
wait
' } + }) + expect(wrapper.find('.custom-loading').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(false) + }) }) - it('lets callers override the loading slot', () => { - const wrapper = mountHost( - { loading: ref(true) }, - { slots: { loading: '
wait
' } } - ) - expect(wrapper.find('.custom-loading').exists()).toBe(true) - expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(false) + describe('with auto-resolution from the registry', () => { + // We mock `useExtensionRegistry` entirely (above), so seeding it is + // synchronous: each test sets the candidates the host's resolved- + // extension computed will see. + const seedRegistry = (candidates: ResourceEditorExtension[]) => { + vi.mocked(useExtensionRegistry).mockReturnValue({ + requestExtensions: vi.fn(() => candidates as any), + registerExtensions: vi.fn(), + unregisterExtensions: vi.fn(), + getExtensionById: vi.fn(), + registerExtensionPoints: vi.fn(), + unregisterExtensionPoints: vi.fn(), + getExtensionPoints: vi.fn() + } as any) + } + + it('renders the #no-editor slot when no candidate matches the resource', () => { + seedRegistry([]) + const wrapper = mount(ResourceEditorHost, { + props: { resource: buildResource(), space: buildSpace() }, + slots: { 'no-editor': '
no editor for me
' }, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) + expect(wrapper.find('.nope').exists()).toBe(true) + }) + + it('picks an extension by exact file extension match', () => { + const pdfViewer = buildExtension({ id: 'app.pdf', appId: 'pdf-viewer', extensions: ['pdf'] }) + seedRegistry([pdfViewer]) + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState()) + const wrapper = mount(ResourceEditorHost, { + props: { resource: buildResource({ extension: 'pdf' }), space: buildSpace() }, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) + expect(wrapper.find('.editor-stub').exists()).toBe(true) + }) + + it('picks an extension by mimeType glob match', () => { + const textEditor = buildExtension({ + id: 'app.text', + appId: 'text-editor', + mimeTypes: ['text/*'] + }) + seedRegistry([textEditor]) + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState()) + const wrapper = mount(ResourceEditorHost, { + props: { + resource: buildResource({ extension: 'whatever', mimeType: 'text/markdown' }), + space: buildSpace() + }, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) + expect(wrapper.find('.editor-stub').exists()).toBe(true) + }) + + it('prefers an extension with hasPriority when multiple match', () => { + const fallback = buildExtension({ + id: 'app.fallback', + appId: 'fallback', + extensions: ['md'], + component: defineComponent({ template: '
' }) + }) + const priority = buildExtension({ + id: 'app.priority', + appId: 'priority', + extensions: ['md'], + hasPriority: true, + component: defineComponent({ template: '
' }) + }) + seedRegistry([fallback, priority]) + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState()) + const wrapper = mount(ResourceEditorHost, { + props: { resource: buildResource({ extension: 'md' }), space: buildSpace() }, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) + expect(wrapper.find('.priority-stub').exists()).toBe(true) + expect(wrapper.find('.fallback-stub').exists()).toBe(false) + }) + + it('honours the `extensionId` prop as an explicit override', () => { + const editorA = buildExtension({ + id: 'app.a', + appId: 'a', + extensions: ['md'], + component: defineComponent({ template: '
' }) + }) + const editorB = buildExtension({ + id: 'app.b', + appId: 'b', + extensions: ['md'], + component: defineComponent({ template: '
' }) + }) + seedRegistry([editorA, editorB]) + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState()) + const wrapper = mount(ResourceEditorHost, { + props: { + resource: buildResource({ extension: 'md' }), + space: buildSpace(), + extensionId: 'app.b' + }, + global: { plugins: [...defaultPlugins()], stubs: { OcSpinner: true } } + }) + expect(wrapper.find('.b-stub').exists()).toBe(true) + expect(wrapper.find('.a-stub').exists()).toBe(false) + }) }) }) diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts index 1b25bd42dd..6c540fa1ef 100644 --- a/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts @@ -2,21 +2,21 @@ import { defineComponent, ref } from 'vue' import { mock } from 'vitest-mock-extended' import { defaultComponentMocks, defaultPlugins, mount } from '@opencloud-eu/web-test-helpers' import ResourceEditorRouteHost from '../../../../src/components/AppTemplates/ResourceEditorRouteHost.vue' -import { useResourceEditor } from '../../../../src/composables/resourceEditor' +import { useResourceEditor, useRouteFileLoader } from '../../../../src/composables/resourceEditor' import { useExtensionRegistry } from '../../../../src/composables/piniaStores' import type { ResourceEditorExtension } from '../../../../src/composables/piniaStores' -import type { Resource } from '@opencloud-eu/web-client' +import type { Resource, SpaceResource } from '@opencloud-eu/web-client' vi.mock('../../../../src/composables/resourceEditor/useResourceEditor') +vi.mock('../../../../src/composables/resourceEditor/useRouteFileLoader') type UseResourceEditorReturn = ReturnType +type UseRouteFileLoaderReturn = ReturnType const buildEditorState = ( overrides: Partial = {} ): UseResourceEditorReturn => ({ - resource: ref(mock({ id: 'r1', name: 'doc.pdf' })), - space: ref(undefined), url: ref(''), currentContent: ref(''), serverContent: ref(''), @@ -28,11 +28,8 @@ const buildEditorState = ( isEditor: ref(false), applicationConfig: ref({}), currentFileContext: ref({ fileName: 'doc.pdf' }), - activeFiles: ref([]), - isFolderLoading: ref(false), save: vi.fn(), closeApp: vi.fn(), - loadFolderForFileContext: vi.fn(), getUrlForResource: vi.fn(), revokeUrl: vi.fn(), setCurrentContent: vi.fn(), @@ -42,6 +39,22 @@ const buildEditorState = ( ...overrides }) as unknown as UseResourceEditorReturn +const buildLoaderState = ( + overrides: Partial = {} +): UseRouteFileLoaderReturn => + ({ + resource: ref(mock({ id: 'r1', name: 'doc.pdf' })), + space: ref(mock()), + loading: ref(false), + loadingError: ref(null), + setResource: vi.fn(), + closeApp: vi.fn(), + activeFiles: ref([]), + isFolderLoading: ref(false), + loadFolderForFileContext: vi.fn(), + ...overrides + }) as unknown as UseRouteFileLoaderReturn + const buildExtension = (): ResourceEditorExtension => ({ id: 'app.test', type: 'resourceEditor', @@ -49,8 +62,14 @@ const buildExtension = (): ResourceEditorExtension => ({ component: defineComponent({ template: '
' }) }) -const mountHost = (overrides: Partial = {}) => { - vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(overrides)) +const mountHost = ( + options: { + editor?: Partial + loader?: Partial + } = {} +) => { + vi.mocked(useResourceEditor).mockReturnValue(buildEditorState(options.editor)) + vi.mocked(useRouteFileLoader).mockReturnValue(buildLoaderState(options.loader)) const mocks = defaultComponentMocks() return mount(ResourceEditorRouteHost, { props: { extension: buildExtension() }, @@ -72,14 +91,20 @@ const mountHost = (overrides: Partial = {}) => { } describe('ResourceEditorRouteHost', () => { - it('renders the loading partial while loading', () => { - const wrapper = mountHost({ loading: ref(true) }) + it('renders the loading partial while the route loader is still loading', () => { + const wrapper = mountHost({ loader: { loading: ref(true) as any } }) + expect(wrapper.find('.editor-stub').exists()).toBe(false) + expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) + }) + + it('renders the loading partial while the file loader is still loading', () => { + const wrapper = mountHost({ editor: { loading: ref(true) as any } }) expect(wrapper.find('.editor-stub').exists()).toBe(false) expect(wrapper.findComponent({ name: 'LoadingScreen' }).exists()).toBe(true) }) - it('renders the error partial when loadingError is set', () => { - const wrapper = mountHost({ loadingError: ref(new Error('nope')) }) + it('renders the error partial when the route loader reports an error', () => { + const wrapper = mountHost({ loader: { loadingError: ref(new Error('nope')) as any } }) expect(wrapper.find('.editor-stub').exists()).toBe(false) const err = wrapper.findComponent({ name: 'ErrorScreen' }) expect(err.exists()).toBe(true) @@ -97,9 +122,9 @@ describe('ResourceEditorRouteHost', () => { expect(wrapper.find('main').attributes('id')).toBe('test-app') }) - it('invokes closeApp when ESC is pressed', async () => { + it('invokes the composable closeApp when ESC is pressed', async () => { const closeApp = vi.fn() - const wrapper = mountHost({ closeApp }) + const wrapper = mountHost({ editor: { closeApp } }) await wrapper.find('main').trigger('keydown.esc') expect(closeApp).toHaveBeenCalled() }) diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/resolveResourceEditor.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/resolveResourceEditor.spec.ts new file mode 100644 index 0000000000..a46f5f4500 --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/resolveResourceEditor.spec.ts @@ -0,0 +1,95 @@ +import { defineComponent } from 'vue' +import type { Resource } from '@opencloud-eu/web-client' +import { + matchesMimePattern, + resolveResourceEditor +} from '../../../../src/components/AppTemplates/resolveResourceEditor' +import type { + ResourceEditorComponent, + ResourceEditorExtension +} from '../../../../src/composables/piniaStores' + +const stubComponent = defineComponent({ + template: '
' +}) as unknown as ResourceEditorComponent + +const ext = (overrides: Partial): ResourceEditorExtension => ({ + id: overrides.id ?? 'app.test', + type: 'resourceEditor', + appId: overrides.appId ?? 'test', + component: stubComponent, + ...overrides +}) + +const resource = (overrides: Partial = {}): Resource => + ({ id: 'r1', name: 'doc', ...overrides }) as Resource + +describe('matchesMimePattern', () => { + it.each([ + ['text/plain', 'text/plain', true], + ['text/plain', 'text/*', true], + ['text/markdown', 'text/*', true], + ['image/png', 'text/*', false], + ['application/pdf', 'application/pdf', true], + ['application/pdf', 'application/*', true], + ['text/plain', 'text/markdown', false] + ])('matches %j against %j → %s', (mime, pattern, expected) => { + expect(matchesMimePattern(mime, pattern)).toBe(expected) + }) +}) + +describe('resolveResourceEditor', () => { + it('returns undefined when no candidate matches', () => { + const editors = [ext({ id: 'a', extensions: ['md'] })] + expect(resolveResourceEditor(resource({ extension: 'pdf' }), editors)).toBeUndefined() + }) + + it('matches by exact file extension', () => { + const md = ext({ id: 'a', extensions: ['md'] }) + const pdf = ext({ id: 'b', extensions: ['pdf'] }) + expect(resolveResourceEditor(resource({ extension: 'pdf' }), [md, pdf])).toBe(pdf) + }) + + it('matches by exact mime type', () => { + const png = ext({ id: 'a', mimeTypes: ['image/png'] }) + expect(resolveResourceEditor(resource({ mimeType: 'image/png' }), [png])).toBe(png) + }) + + it('matches by mime glob (`family/*`)', () => { + const text = ext({ id: 'a', mimeTypes: ['text/*'] }) + expect(resolveResourceEditor(resource({ mimeType: 'text/markdown' }), [text])).toBe(text) + }) + + it('honours a custom matches() predicate', () => { + const custom = ext({ + id: 'a', + matches: (r) => r.name === 'special.x' + }) + expect(resolveResourceEditor(resource({ name: 'special.x' }), [custom])).toBe(custom) + expect(resolveResourceEditor(resource({ name: 'other' }), [custom])).toBeUndefined() + }) + + it('prefers a hasPriority candidate among multiple matches', () => { + const fallback = ext({ id: 'a', extensions: ['md'] }) + const priority = ext({ id: 'b', extensions: ['md'], hasPriority: true }) + // Order in the list should not matter. + expect(resolveResourceEditor(resource({ extension: 'md' }), [fallback, priority])).toBe( + priority + ) + expect(resolveResourceEditor(resource({ extension: 'md' }), [priority, fallback])).toBe( + priority + ) + }) + + it('falls back to the first match when no candidate has priority', () => { + const a = ext({ id: 'a', extensions: ['md'] }) + const b = ext({ id: 'b', extensions: ['md'] }) + expect(resolveResourceEditor(resource({ extension: 'md' }), [a, b])).toBe(a) + }) + + it('lower-cases extension and mime before matching', () => { + const text = ext({ id: 'a', extensions: ['md'], mimeTypes: ['text/*'] }) + expect(resolveResourceEditor(resource({ extension: 'MD' }), [text])).toBe(text) + expect(resolveResourceEditor(resource({ mimeType: 'TEXT/MARKDOWN' }), [text])).toBe(text) + }) +}) diff --git a/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts b/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts index f4a667961c..95f749729f 100644 --- a/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts +++ b/packages/web-pkg/tests/unit/components/AppTemplates/resourceEditorRoute.spec.ts @@ -47,7 +47,7 @@ describe('resourceEditorRoute', () => { it('produces a component that re-renders on every mount (factory-shaped)', () => { const route = resourceEditorRoute({ extension: buildExtension() }) - // The host component is built inline — assert it carries a render fn so + // The host component is built inline, assert it carries a render fn so // the route record is mountable. expect(route.component).toBeDefined() expect((route.component as { render?: unknown }).render).toBeTypeOf('function') diff --git a/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts b/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts index 327e4df4a4..a1bfd17239 100644 --- a/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts +++ b/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts @@ -1,25 +1,21 @@ -import { defineComponent, nextTick, unref } from 'vue' +import { defineComponent, nextTick, ref, type Ref } from 'vue' import { mock } from 'vitest-mock-extended' -import { - defaultComponentMocks, - getComposableWrapper, - useAppDefaultsMock -} from '@opencloud-eu/web-test-helpers' -import { HttpError, Resource } from '@opencloud-eu/web-client' +import { defaultComponentMocks, getComposableWrapper } from '@opencloud-eu/web-test-helpers' +import { HttpError, Resource, SpaceResource } from '@opencloud-eu/web-client' import { useResourceEditor } from '../../../../src/composables/resourceEditor/useResourceEditor' -import { useAppDefaults } from '../../../../src/composables/appDefaults' +import { useAppFileHandling } from '../../../../src/composables/appDefaults/useAppFileHandling' import { useMessages } from '../../../../src/composables/piniaStores' import type { ResourceEditorComponent, ResourceEditorExtension } from '../../../../src/composables/piniaStores' -vi.mock('../../../../src/composables/appDefaults', async (importOriginal) => ({ +vi.mock('../../../../src/composables/appDefaults/useAppFileHandling', async (importOriginal) => ({ ...(await importOriginal()), - useAppDefaults: vi.fn() + useAppFileHandling: vi.fn() })) -type AppDefaultsResult = ReturnType +type AppFileHandlingResult = ReturnType const httpError = (statusCode: number) => Object.assign(new Error(`HTTP ${statusCode}`), { @@ -48,7 +44,7 @@ const componentWith = ( props: Record = { url: { type: String, required: false } } ) => // defineComponent's typed emits don't line up with the method-shorthand - // `onUpdate:*` bindings on ResourceEditorBindings — we only care about + // `onUpdate:*` bindings on ResourceEditorBindings, we only care about // the runtime props/emits introspection here, so cast away the structural // mismatch. defineComponent({ @@ -57,51 +53,115 @@ const componentWith = ( template: '
' }) as unknown as ResourceEditorComponent -const selfLoadingExtension = (overrides: Partial = {}) => - buildExtension({ component: componentWith(['update:resource']), ...overrides }) +const viewerWithUrl = (overrides: Partial = {}) => + buildExtension({ + component: componentWith([], { url: { type: String, required: false } }), + ...overrides + }) + +const viewerWithContent = (overrides: Partial = {}) => + buildExtension({ + component: componentWith([], { currentContent: { type: String, required: false } }), + ...overrides + }) const editorExtension = (overrides: Partial = {}) => buildExtension({ - component: componentWith(['update:resource', 'update:currentContent']), + component: componentWith(['update:currentContent'], { + currentContent: { type: String, required: false } + }), + ...overrides + }) + +// Editor without a `currentContent` prop, useful for tests that exercise +// save/dirty/autosave paths without the noise of `loadFileTask` racing the +// assertion. The composable's `isEditor` flag still flips true because of +// the emit, but no auto-content-load runs. +const pureEditor = (overrides: Partial = {}) => + buildExtension({ + component: componentWith(['update:currentContent'], {}), ...overrides }) +const buildResource = (overrides: Partial = {}): Resource => + ({ + id: 'r1', + name: 'doc.txt', + path: '/doc.txt', + permissions: 'WCK', + extension: 'txt', + ...overrides + }) as Resource + +const buildSpace = (overrides: Partial = {}): SpaceResource => + ({ id: 's1', webDavPath: '/dav/spaces/s1', ...overrides }) as SpaceResource + +const buildFileHandling = (overrides: Partial = {}): AppFileHandlingResult => + ({ + getFileInfo: vi.fn(), + getFileContents: vi.fn().mockResolvedValue({ body: '', headers: { 'OC-ETag': 'etag-0' } }), + putFileContents: vi.fn().mockResolvedValue({ etag: 'etag-new' }), + getUrlForResource: vi.fn().mockResolvedValue(''), + revokeUrl: vi.fn(), + ...overrides + }) as unknown as AppFileHandlingResult + interface BuildOptions { extension?: ResourceEditorExtension - appDefaults?: Partial + resource?: Ref + space?: Ref + fileHandling?: Partial autosaveEnabled?: boolean autosaveInterval?: number + onClose?: () => void + onResourceUpdate?: (r: Resource) => void } const buildWrapper = ({ extension = buildExtension(), - appDefaults = {}, + resource = ref(buildResource()), + space = ref(buildSpace()), + fileHandling = {}, autosaveEnabled, - autosaveInterval + autosaveInterval, + onClose, + onResourceUpdate }: BuildOptions = {}) => { - vi.mocked(useAppDefaults).mockReturnValue(useAppDefaultsMock(appDefaults)) + vi.mocked(useAppFileHandling).mockReturnValue(buildFileHandling(fileHandling)) const mocks = defaultComponentMocks() - return getComposableWrapper(() => ({ ...useResourceEditor({ extension }) }), { - mocks, - // Provide `$router`/`$route` etc. as injects too — useRouter() / useRoute() - // read them via vue's inject API, not as Options-style this.$router. - provide: mocks, - pluginOptions: { - piniaOptions: { - configState: { - options: { - editor: { autosaveEnabled, autosaveInterval } - } as any + return getComposableWrapper( + () => ({ + ...useResourceEditor({ + extension, + resource: () => resource.value, + space: () => space.value, + onClose, + onResourceUpdate + }) + }), + { + mocks, + provide: mocks, + pluginOptions: { + piniaOptions: { + configState: { + options: { + editor: { autosaveEnabled, autosaveInterval } + } as any + }, + appsState: { + apps: { 'test-app': { id: 'test-app', name: 'Test app' } } + } } } } - }) + ) } describe('useResourceEditor', () => { describe('editor vs viewer detection', () => { it('flags components without `update:currentContent` as viewers', () => { - const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + const wrapper = buildWrapper({ extension: viewerWithUrl() }) expect(wrapper.vm.isEditor).toBe(false) }) @@ -113,22 +173,24 @@ describe('useResourceEditor', () => { describe('content/resource setters', () => { it('setCurrentContent updates the currentContent ref', () => { - const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + const wrapper = buildWrapper({ extension: editorExtension() }) wrapper.vm.setCurrentContent('hello') expect(wrapper.vm.currentContent).toBe('hello') }) - it('setResource updates the resource ref', () => { - const wrapper = buildWrapper({ extension: selfLoadingExtension() }) - const next = mock({ id: 'next', name: 'next.txt' }) + it('setResource calls onResourceUpdate (caller owns the resource ref)', () => { + const onResourceUpdate = vi.fn() + const wrapper = buildWrapper({ extension: editorExtension(), onResourceUpdate }) + const next = buildResource({ id: 'next', name: 'next.txt' }) wrapper.vm.setResource(next) - expect((wrapper.vm.resource as Resource | undefined)?.id).toBe('next') + expect(onResourceUpdate).toHaveBeenCalledWith(next) }) it('isDirty toggles once setCurrentContent diverges from serverContent', async () => { const wrapper = buildWrapper({ extension: editorExtension() }) - expect(wrapper.vm.isDirty).toBe(false) - wrapper.vm.setCurrentContent('changed') + await nextTick() + const stable = wrapper.vm.serverContent + wrapper.vm.setCurrentContent(stable === 'changed' ? 'changed!' : 'changed') await nextTick() expect(wrapper.vm.isDirty).toBe(true) }) @@ -136,19 +198,73 @@ describe('useResourceEditor', () => { describe('delete-resource callback registration', () => { it('stores the registered callback on the returned ref', () => { - const wrapper = buildWrapper({ extension: selfLoadingExtension() }) + const wrapper = buildWrapper({ extension: editorExtension() }) const cb = vi.fn() wrapper.vm.registerOnDeleteResourceCallback(cb) expect(wrapper.vm.deleteResourceCallback).toBe(cb) }) }) + describe('capability-driven file loading', () => { + it('resolves url via getUrlForResource when component declares the `url` prop', async () => { + const getUrlForResource = vi.fn().mockResolvedValue('https://files/r1/blob') + const wrapper = buildWrapper({ + extension: viewerWithUrl(), + fileHandling: { getUrlForResource } + }) + // The watch on resource is `immediate: true`, loadFileTask runs in a + // microtask. Allow a few flushes for vue-concurrency's setTimeout(0). + await new Promise((r) => setTimeout(r, 0)) + await nextTick() + expect(getUrlForResource).toHaveBeenCalled() + expect(wrapper.vm.url).toBe('https://files/r1/blob') + }) + + it('resolves currentContent via getFileContents when component declares the prop', async () => { + const getFileContents = vi + .fn() + .mockResolvedValue({ body: 'file contents', headers: { 'OC-ETag': 'etag-x' } }) + const wrapper = buildWrapper({ + extension: viewerWithContent(), + fileHandling: { getFileContents } + }) + await new Promise((r) => setTimeout(r, 0)) + await nextTick() + expect(getFileContents).toHaveBeenCalled() + expect(wrapper.vm.currentContent).toBe('file contents') + expect(wrapper.vm.serverContent).toBe('file contents') + }) + + it('re-runs loadFileTask when the resource changes', async () => { + const getUrlForResource = vi + .fn() + .mockResolvedValueOnce('https://files/r1/blob') + .mockResolvedValueOnce('https://files/r2/blob') + const resource = ref(buildResource({ id: 'r1' })) + const wrapper = buildWrapper({ + extension: viewerWithUrl(), + resource, + fileHandling: { getUrlForResource } + }) + await new Promise((r) => setTimeout(r, 0)) + await nextTick() + expect(wrapper.vm.url).toBe('https://files/r1/blob') + + resource.value = buildResource({ id: 'r2' }) + await nextTick() + await new Promise((r) => setTimeout(r, 0)) + await nextTick() + expect(getUrlForResource).toHaveBeenCalledTimes(2) + expect(wrapper.vm.url).toBe('https://files/r2/blob') + }) + }) + describe('save', () => { it('writes currentContent via putFileContents and clears the dirty flag on success', async () => { const putFileContents = vi.fn().mockResolvedValue({ etag: 'new-etag' } as any) const wrapper = buildWrapper({ - extension: editorExtension(), - appDefaults: { putFileContents } + extension: pureEditor(), + fileHandling: { putFileContents } }) wrapper.vm.setCurrentContent('payload') await nextTick() @@ -167,15 +283,16 @@ describe('useResourceEditor', () => { it('reports a conflict error on 412 / 409 without touching serverContent', async () => { const putFileContents = vi.fn().mockRejectedValue(httpError(412)) const wrapper = buildWrapper({ - extension: editorExtension(), - appDefaults: { putFileContents } + extension: pureEditor(), + fileHandling: { putFileContents } }) + const initialServerContent = wrapper.vm.serverContent wrapper.vm.setCurrentContent('local edits') await nextTick() await wrapper.vm.save() - expect(wrapper.vm.serverContent).toBeUndefined() + expect(wrapper.vm.serverContent).toBe(initialServerContent) expect(wrapper.vm.isDirty).toBe(true) const { showErrorMessage } = useMessages() expect(showErrorMessage).toHaveBeenCalled() @@ -186,8 +303,8 @@ describe('useResourceEditor', () => { it('reports an auth error on 401 / 403', async () => { const putFileContents = vi.fn().mockRejectedValue(httpError(403)) const wrapper = buildWrapper({ - extension: editorExtension(), - appDefaults: { putFileContents } + extension: pureEditor(), + fileHandling: { putFileContents } }) wrapper.vm.setCurrentContent('payload') await nextTick() @@ -201,8 +318,8 @@ describe('useResourceEditor', () => { it('reports the no-quota error on 507', async () => { const putFileContents = vi.fn().mockRejectedValue(httpError(507)) const wrapper = buildWrapper({ - extension: editorExtension(), - appDefaults: { putFileContents } + extension: pureEditor(), + fileHandling: { putFileContents } }) wrapper.vm.setCurrentContent('payload') await nextTick() @@ -217,7 +334,7 @@ describe('useResourceEditor', () => { describe('autosave wiring', () => { it('does not start an autosave interval for viewers (no update:currentContent emit)', () => { const spy = vi.spyOn(global, 'setInterval') - buildWrapper({ extension: selfLoadingExtension(), autosaveEnabled: true }) + buildWrapper({ extension: viewerWithUrl(), autosaveEnabled: true }) expect(spy).not.toHaveBeenCalled() spy.mockRestore() }) @@ -247,20 +364,32 @@ describe('useResourceEditor', () => { }) }) + describe('onClose wiring', () => { + it('invokes the onClose callback when closeApp is called', () => { + const onClose = vi.fn() + const wrapper = buildWrapper({ extension: viewerWithUrl(), onClose }) + wrapper.vm.closeApp() + expect(onClose).toHaveBeenCalled() + }) + }) + describe('beforeunload listener', () => { it('attaches a beforeunload listener once isDirty flips true, removes it once it flips back', async () => { const add = vi.spyOn(window, 'addEventListener') const remove = vi.spyOn(window, 'removeEventListener') - const wrapper = buildWrapper({ extension: editorExtension() }) + const wrapper = buildWrapper({ extension: pureEditor() }) wrapper.vm.setCurrentContent('typed') await nextTick() - expect(add).toHaveBeenCalledWith('beforeunload', expect.any(Function)) + // happy-dom passes a 3rd `options` arg to addEventListener internally - + // we only care that *some* call targets `beforeunload`, not the exact + // signature. + expect(add.mock.calls.some(([type]) => type === 'beforeunload')).toBe(true) - // Roll currentContent back to the (undefined) serverContent — dirty=false again. + // Roll currentContent back to the serverContent, dirty=false again. wrapper.vm.setCurrentContent(wrapper.vm.serverContent) await nextTick() - expect(remove).toHaveBeenCalledWith('beforeunload', expect.any(Function)) + expect(remove.mock.calls.some(([type]) => type === 'beforeunload')).toBe(true) add.mockRestore() remove.mockRestore() From 42f7b833e1b40b4a02517f21ad4f69ac27c3309a Mon Sep 17 00:00:00 2001 From: Dominik Schmidt Date: Thu, 21 May 2026 03:02:43 +0200 Subject: [PATCH 3/3] fix(resourceEditor): close review-found correctness gaps Three issues from a thorough multi-reviewer pass on the branch: 1. `ResourceEditorHost` constructed `useResourceEditor` unconditionally with `extension: () => resolvedExtension.value!`. When no extension matched (e.g. a resource the registry can't handle), the composable read `unref(extensionRef).appId` and crashed before the template could render the `#no-editor` slot. Split the host into an outer shell that gates on `resolvedExtension` and an inner `ResourceEditorMount` that runs the composable only once an extension is resolved. Mount stays internal to the package. 2. `web-app-epub-reader` wrapped its App in `defineAsyncComponent` to defer the epubjs bundle. The wrapper has no `props`/`emits` until the chunk resolves, so the composable's capability introspection (`hasProp('currentContent')`) returned `false` and content was never loaded. App.vue is now statically imported and the epubjs dependency itself is dynamically imported inside App.vue's setup, keeping the heavy lib deferred while exposing the component's props/emits to the host. Test helper exports `flushPromises` so the existing spec can wait for the dynamic import. 3. `useResourceEditor`'s `fileSizeLimit` modal was dispatched whenever `resource` or `space` changed. Preview's photo-roll re-fires that watcher on every active-file swap, so the modal popped on every click. Track the last resource id we prompted for and only re-dispatch on actual transitions. --- packages/web-app-epub-reader/src/App.vue | 3 +- packages/web-app-epub-reader/src/index.ts | 6 +- .../tests/unit/app.spec.ts | 18 +++ .../AppTemplates/ResourceEditorHost.vue | 113 ++++-------------- .../AppTemplates/ResourceEditorMount.vue | 105 ++++++++++++++++ .../resourceEditor/useResourceEditor.ts | 8 +- packages/web-test-helpers/src/helpers.ts | 2 +- 7 files changed, 158 insertions(+), 97 deletions(-) create mode 100644 packages/web-pkg/src/components/AppTemplates/ResourceEditorMount.vue diff --git a/packages/web-app-epub-reader/src/App.vue b/packages/web-app-epub-reader/src/App.vue index b7543e7e8c..e59cb3b47c 100644 --- a/packages/web-app-epub-reader/src/App.vue +++ b/packages/web-app-epub-reader/src/App.vue @@ -101,7 +101,7 @@ import { useLocalStorage, useThemeStore } from '@opencloud-eu/web-pkg' -import ePub, { Book, NavItem, Rendition, Location } from 'epubjs' +import type { Book, NavItem, Rendition, Location } from 'epubjs' const DARK_THEME_CONFIG = { html: { @@ -200,6 +200,7 @@ export default defineComponent({ {} ) + const { default: ePub } = await import('epubjs') book.value = ePub(props.currentContent) unref(book).loaded.navigation.then(({ toc }) => { diff --git a/packages/web-app-epub-reader/src/index.ts b/packages/web-app-epub-reader/src/index.ts index ef27342abf..beb2344129 100644 --- a/packages/web-app-epub-reader/src/index.ts +++ b/packages/web-app-epub-reader/src/index.ts @@ -1,4 +1,4 @@ -import { computed, defineAsyncComponent } from 'vue' +import { computed } from 'vue' import { useGettext } from 'vue3-gettext' import translations from '../l10n/translations.json' import { @@ -6,15 +6,13 @@ import { resourceEditorRoute, type ResourceEditorExtension } from '@opencloud-eu/web-pkg' +import EpubReader from './App.vue' export default defineWebApplication({ setup() { const { $gettext } = useGettext() const appId = 'epub-reader' - // Defer the epubjs bundle until the editor actually renders. - const EpubReader = defineAsyncComponent(() => import('./App.vue')) - const extension: ResourceEditorExtension = { id: 'app.epub-reader', type: 'resourceEditor', diff --git a/packages/web-app-epub-reader/tests/unit/app.spec.ts b/packages/web-app-epub-reader/tests/unit/app.spec.ts index 02fe2af567..b768adbf3c 100644 --- a/packages/web-app-epub-reader/tests/unit/app.spec.ts +++ b/packages/web-app-epub-reader/tests/unit/app.spec.ts @@ -1,6 +1,7 @@ import { PartialComponentProps, defaultPlugins, + flushPromises, getOcSelectOptions, mount, nextTicks @@ -56,12 +57,14 @@ describe('Epub reader app', () => { it('renders correctly', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() expect(wrapper.html()).toMatchSnapshot() }) describe('theme', () => { it('sets the theme based on current theme setting', async () => { const { wrapper } = getWrapper({ localStorageGeneral: { fontSizePercentage: 50 } }) await nextTicks(2) + await flushPromises() expect(wrapper.vm.rendition.themes.select).toHaveBeenCalledWith('light') }) }) @@ -69,17 +72,20 @@ describe('Epub reader app', () => { it('initializes with default font size percentage', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() expect(wrapper.vm.rendition.themes.fontSize).toHaveBeenCalledWith('100%') }) it('initializes with local storage font size when set', async () => { const { wrapper } = getWrapper({ localStorageGeneral: { fontSizePercentage: 50 } }) await nextTicks(2) + await flushPromises() expect(wrapper.vm.rendition.themes.fontSize).toHaveBeenCalledWith('50%') }) describe('increase font size button', () => { it('increases font size when clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() await wrapper.find(selectors.increaseFontSize).trigger('click') expect(wrapper.vm.rendition.themes.fontSize).toHaveBeenCalledWith('110%') }) @@ -94,6 +100,7 @@ describe('Epub reader app', () => { it('decreases font size when clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() await wrapper.find(selectors.decreaseFontSize).trigger('click') expect(wrapper.vm.rendition.themes.fontSize).toHaveBeenCalledWith('90%') }) @@ -108,12 +115,14 @@ describe('Epub reader app', () => { it('resets font size when clicked', async () => { const { wrapper } = getWrapper({ localStorageGeneral: { fontSizePercentage: 50 } }) await nextTicks(2) + await flushPromises() await wrapper.find(selectors.resetFontSize).trigger('click') expect(wrapper.vm.rendition.themes.fontSize).toHaveBeenCalledWith('100%') }) it('shows the current font size', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() await wrapper.find(selectors.decreaseFontSize).trigger('click') expect(wrapper.find(selectors.resetFontSize).text()).toBe('90%') }) @@ -127,6 +136,7 @@ describe('Epub reader app', () => { } }) await nextTicks(2) + await flushPromises() expect(wrapper.vm.rendition.display).toHaveBeenCalledWith( 'epubcfi(/6/4!/4/4/14/2/150/2/1:23)' ) @@ -137,6 +147,7 @@ describe('Epub reader app', () => { it('renders correctly', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const chapterElements = wrapper.findAll(selectors.chaptersListItem) expect(chapterElements.length).toEqual(2) expect(chapterElements[0].text()).toEqual('Chapter 1') @@ -145,6 +156,7 @@ describe('Epub reader app', () => { it('calls method "display" when item is clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const chapterElements = wrapper.findAll(selectors.chaptersListItem) await chapterElements[1].find('.oc-button').trigger('click') expect(wrapper.vm.rendition.display).toHaveBeenCalledWith('c2') @@ -154,6 +166,7 @@ describe('Epub reader app', () => { it('renders correctly', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const chapterElements = await getOcSelectOptions(wrapper, selectors.chaptersSelect) expect(chapterElements.length).toEqual(2) expect(chapterElements[0].text()).toEqual('Chapter 1') @@ -162,6 +175,7 @@ describe('Epub reader app', () => { it('calls method "display" when item is clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const chapterElements = await getOcSelectOptions(wrapper, selectors.chaptersSelect, { close: false }) @@ -175,6 +189,7 @@ describe('Epub reader app', () => { it('calls method "prev" when left arrow key is pressed', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const keyboardEvent = new KeyboardEvent('keydown', { key: 'ArrowLeft' }) document.dispatchEvent(keyboardEvent) expect(wrapper.vm.rendition.prev).toHaveBeenCalled() @@ -182,6 +197,7 @@ describe('Epub reader app', () => { it('calls method "next" when right arrow key is pressed', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() const keyboardEvent = new KeyboardEvent('keydown', { key: 'ArrowRight' }) document.dispatchEvent(keyboardEvent) expect(wrapper.vm.rendition.next).toHaveBeenCalled() @@ -191,6 +207,7 @@ describe('Epub reader app', () => { it('calls method "prev" when clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() await wrapper.find(selectors.navigateLeft).trigger('click') expect(wrapper.vm.rendition.prev).toHaveBeenCalled() }) @@ -199,6 +216,7 @@ describe('Epub reader app', () => { it('calls method "next" when clicked', async () => { const { wrapper } = getWrapper() await nextTicks(2) + await flushPromises() await wrapper.find(selectors.navigateRight).trigger('click') expect(wrapper.vm.rendition.next).toHaveBeenCalled() }) diff --git a/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue index e4c59b4811..05062a8fdc 100644 --- a/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue @@ -4,43 +4,30 @@ {{ $gettext('No preview available for this file.') }}

- - - - - - - - - + + diff --git a/packages/web-pkg/src/components/AppTemplates/ResourceEditorMount.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorMount.vue new file mode 100644 index 0000000000..78d65a7f43 --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorMount.vue @@ -0,0 +1,105 @@ + + + diff --git a/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts index b7c9b444d1..a2ba604028 100644 --- a/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts +++ b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts @@ -190,6 +190,10 @@ export function useResourceEditor(options: UseResourceEditorOptions) { } }).restartable() + // The size modal should be shown once per resource. Preview's photo-roll + // re-fires this watcher on every active-file swap; tracking the last + // resource id we prompted for keeps the modal from re-popping. + let sizePromptedFor: string | undefined watch( [() => unref(resource), () => unref(space)], ([r]) => { @@ -197,7 +201,9 @@ export function useResourceEditor(options: UseResourceEditorOptions) { return } const limit = unref(fileSizeLimit) - if (limit && toNumber(r.size) > limit) { + const exceedsLimit = limit && toNumber(r.size) > limit + if (exceedsLimit && sizePromptedFor !== r.id) { + sizePromptedFor = r.id dispatchModal({ title: $gettext('File exceeds %{threshold}', { threshold: formatFileSize(limit, currentLanguage) diff --git a/packages/web-test-helpers/src/helpers.ts b/packages/web-test-helpers/src/helpers.ts index 7bcc4841b3..14db4c346a 100644 --- a/packages/web-test-helpers/src/helpers.ts +++ b/packages/web-test-helpers/src/helpers.ts @@ -3,7 +3,7 @@ import { defineComponent, nextTick } from 'vue' import { createRouter as _createRouter, createMemoryHistory, RouterOptions } from 'vue-router' import { defaultPlugins, DefaultPluginsOptions } from './defaultPlugins' -export { mount, shallowMount } from '@vue/test-utils' +export { mount, shallowMount, flushPromises } from '@vue/test-utils' vi.spyOn(console, 'warn').mockImplementation(() => undefined)