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 af1f625308..beb2344129 100644 --- a/packages/web-app-epub-reader/src/index.ts +++ b/packages/web-app-epub-reader/src/index.ts @@ -1,34 +1,26 @@ +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 EpubReader from './App.vue' 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 - } - } - ] + const extension: ResourceEditorExtension = { + id: 'app.epub-reader', + type: 'resourceEditor', + appId, + extensions: ['epub'], + component: EpubReader, + fileContentOptions: { responseType: 'blob' } + } return { appInfo: { @@ -38,11 +30,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-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-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..01da3ef81e 100644 --- a/packages/web-app-pdf-viewer/src/index.ts +++ b/packages/web-app-pdf-viewer/src/index.ts @@ -1,31 +1,27 @@ +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, + extensions: ['pdf'], + mimeTypes: ['application/pdf'], + component: PdfViewer, + urlForResourceOptions: { disposition: 'inline' } + } return { appInfo: { @@ -37,11 +33,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..1c06904a64 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, + mimeTypes, + component: App, + urlForResourceOptions: { disposition: 'inline' } + } + + // 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', + 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/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 12ad36ad56..b8d9f5cc85 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,15 @@ 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, + extensions: appFileExtensions.map((e) => e.extension), + component: TextEditor + } const appInfo: ApplicationInformation = { name: $gettext('Text Editor'), @@ -113,14 +109,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 +135,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..51daaba781 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -1,89 +1,22 @@ - - - - - - - - - + - closeApp() + diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts index ee6501ce0c..2ad79037c7 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapperRoute.ts @@ -4,6 +4,7 @@ import { AppWrapperSlotArgs } from './types' import { FileContentOptions, UrlForResourceOptions } from '../../composables' import { Resource } from '@opencloud-eu/web-client' +/** @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 new file mode 100644 index 0000000000..05062a8fdc --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorHost.vue @@ -0,0 +1,82 @@ + + + + {{ $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/components/AppTemplates/ResourceEditorRouteHost.vue b/packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue new file mode 100644 index 0000000000..12d9bc640c --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/ResourceEditorRouteHost.vue @@ -0,0 +1,332 @@ + + + + + + + + + + + + + diff --git a/packages/web-pkg/src/components/AppTemplates/index.ts b/packages/web-pkg/src/components/AppTemplates/index.ts index cfd23625c2..7488867866 100644 --- a/packages/web-pkg/src/components/AppTemplates/index.ts +++ b/packages/web-pkg/src/components/AppTemplates/index.ts @@ -1,3 +1,7 @@ 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 './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 new file mode 100644 index 0000000000..afb1b1ccfa --- /dev/null +++ b/packages/web-pkg/src/components/AppTemplates/resourceEditorRoute.ts @@ -0,0 +1,36 @@ +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 + name?: string + path?: string + authContext?: AuthContext + meta?: WebRouteMeta +} + +/** + * 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 + 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..697c12223b 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,75 @@ export interface AppMenuItemExtension extends Extension { url?: string } +/** + * 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. + * + * 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 { + resource?: Resource + space?: SpaceResource + isReadOnly?: boolean + applicationConfig?: AppConfigObject + currentFileContext?: FileContext + + url?: string + currentContent?: unknown + activeFiles?: Resource[] + isDirty?: boolean + isFolderLoading?: boolean + + loadFolderForFileContext?: AppFolderHandlingResult['loadFolderForFileContext'] + getUrlForResource?: AppFileHandlingResult['getUrlForResource'] + revokeUrl?: AppFileHandlingResult['revokeUrl'] + + // 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 + '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 + + /** 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 + 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..5d03662809 --- /dev/null +++ b/packages/web-pkg/src/composables/resourceEditor/index.ts @@ -0,0 +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 new file mode 100644 index 0000000000..a2ba604028 --- /dev/null +++ b/packages/web-pkg/src/composables/resourceEditor/useResourceEditor.ts @@ -0,0 +1,397 @@ +import { + MaybeRefOrGetter, + computed, + onBeforeUnmount, + onMounted, + ref, + toRef, + unref, + watch +} from 'vue' +import { useTask } from 'vue-concurrency' +import { useGettext } from 'vue3-gettext' +import toNumber from 'lodash-es/toNumber' + +import { + HttpError, + Resource, + SpaceResource, + call, + isProjectSpaceResource, + urlJoin +} from '@opencloud-eu/web-client' +import { DavPermission } from '@opencloud-eu/web-client/webdav' + +import { useAppConfig } from '../appDefaults/useAppConfig' +import { useAppFileHandling } from '../appDefaults/useAppFileHandling' +import { useAppMeta } from '../appDefaults/useAppMeta' +import type { FileContext } from '../appDefaults/types' +import { useClientService } from '../clientService' +import { useEventBus } from '../eventBus' +import { useLoadingService } from '../loadingService' +import { + useAppsStore, + useConfigStore, + useMessages, + useModals, + useResourcesStore, + useSpacesStore, + type ResourceEditorExtension +} from '../piniaStores' +import { formatFileSize } from '../../helpers' + +export interface UseResourceEditorOptions { + extension: MaybeRefOrGetter + resource: MaybeRefOrGetter + space: MaybeRefOrGetter + onClose?: () => void + onResourceUpdate?: (resource: Resource) => void + activeFiles?: MaybeRefOrGetter + isFolderLoading?: MaybeRefOrGetter + loadFolderForFileContext?: (ctx: FileContext) => Promise +} + +/** + * 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) + 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 clientService = useClientService() + const loadingService = useLoadingService() + const { dispatchModal } = useModals() + const { showMessage, showErrorMessage } = useMessages() + const appsStore = useAppsStore() + const spacesStore = useSpacesStore() + const configStore = useConfigStore() + const resourcesStore = useResourcesStore() + const eventBus = useEventBus() + + 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 { 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 + ) + + // 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 loadingError = ref(null) + const isReadOnly = ref(false) + const serverContent = ref() + const currentContent = ref() + 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) + } else { + window.removeEventListener('beforeunload', preventUnload) + } + }) + + const loadFileTask = useTask(function* (signal) { + const r = unref(resource) + const s = unref(space) + if (!r || !s) { + return + } + try { + 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) => (r.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(s, r, { + ...unref(extensionRef).urlForResourceOptions, + signal + }) + } + } catch (e) { + console.error(e) + loadingError.value = e as Error + } finally { + loading.value = false + } + }).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]) => { + if (!r) { + return + } + const limit = unref(fileSizeLimit) + 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) + }), + message: $gettext( + '%{resource} exceeds the recommended size of %{threshold} for editing, and may cause performance issues.', + { + resource: r.name, + threshold: formatFileSize(limit, currentLanguage) + } + ), + confirmText: $gettext('Continue'), + onCancel: () => { + options.onClose?.() + }, + onConfirm: () => { + loadFileTask.perform() + } + }) + } else { + loadFileTask.perform() + } + }, + { 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) + const r = unref(resource) + 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 === r?.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 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)!() + } + closeApp() + } + + let autosaveIntervalId: ReturnType | null = null + + onMounted(() => { + deleteResourceEventToken = eventBus.subscribe( + 'runtime.resource.deleted', + onDeleteResourceCallback + ) + + 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 (autosaveIntervalId) { + clearInterval(autosaveIntervalId) + autosaveIntervalId = null + } + }) + + const setCurrentContent = (value: unknown) => { + currentContent.value = value + } + const setResource = (value: Resource) => { + options.onResourceUpdate?.(value) + } + 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, + getUrlForResource, + revokeUrl, + loadFolderForFileContext: options.loadFolderForFileContext ?? (async () => undefined), + setCurrentContent, + setResource, + registerOnDeleteResourceCallback, + deleteResourceCallback + } +} 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 new file mode 100644 index 0000000000..125c561fab --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorHost.spec.ts @@ -0,0 +1,274 @@ +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 { + 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 = ( + 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 stubComponent = defineComponent({ + props: ['resource'], + template: '' +}) + +const buildExtension = ( + overrides: Partial = {} +): ResourceEditorExtension => ({ + id: 'app.test', + type: 'resourceEditor', + appId: 'test-app', + component: stubComponent, + ...overrides +}) + +// 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', () => { + 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 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('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', () => { + 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) + }) + }) + + 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 new file mode 100644 index 0000000000..6c540fa1ef --- /dev/null +++ b/packages/web-pkg/tests/unit/components/AppTemplates/ResourceEditorRouteHost.spec.ts @@ -0,0 +1,142 @@ +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, useRouteFileLoader } from '../../../../src/composables/resourceEditor' +import { useExtensionRegistry } from '../../../../src/composables/piniaStores' +import 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/resourceEditor/useRouteFileLoader') + +type UseResourceEditorReturn = ReturnType +type UseRouteFileLoaderReturn = ReturnType + +const buildEditorState = ( + overrides: Partial = {} +): UseResourceEditorReturn => + ({ + 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' }), + save: vi.fn(), + closeApp: 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 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', + appId: 'test-app', + component: defineComponent({ template: '' }) +}) + +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() }, + 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 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 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) + 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 the composable closeApp when ESC is pressed', async () => { + const closeApp = vi.fn() + const wrapper = mountHost({ editor: { 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/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 new file mode 100644 index 0000000000..95f749729f --- /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..a1bfd17239 --- /dev/null +++ b/packages/web-pkg/tests/unit/composables/resourceEditor/useResourceEditor.spec.ts @@ -0,0 +1,398 @@ +import { defineComponent, nextTick, ref, type Ref } from 'vue' +import { mock } from 'vitest-mock-extended' +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 { 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/useAppFileHandling', async (importOriginal) => ({ + ...(await importOriginal()), + useAppFileHandling: vi.fn() +})) + +type AppFileHandlingResult = 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 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: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 + resource?: Ref + space?: Ref + fileHandling?: Partial + autosaveEnabled?: boolean + autosaveInterval?: number + onClose?: () => void + onResourceUpdate?: (r: Resource) => void +} + +const buildWrapper = ({ + extension = buildExtension(), + resource = ref(buildResource()), + space = ref(buildSpace()), + fileHandling = {}, + autosaveEnabled, + autosaveInterval, + onClose, + onResourceUpdate +}: BuildOptions = {}) => { + vi.mocked(useAppFileHandling).mockReturnValue(buildFileHandling(fileHandling)) + const mocks = defaultComponentMocks() + 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: viewerWithUrl() }) + 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: editorExtension() }) + wrapper.vm.setCurrentContent('hello') + expect(wrapper.vm.currentContent).toBe('hello') + }) + + 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(onResourceUpdate).toHaveBeenCalledWith(next) + }) + + it('isDirty toggles once setCurrentContent diverges from serverContent', async () => { + const wrapper = buildWrapper({ extension: editorExtension() }) + await nextTick() + const stable = wrapper.vm.serverContent + wrapper.vm.setCurrentContent(stable === 'changed' ? 'changed!' : '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: 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: pureEditor(), + fileHandling: { 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: pureEditor(), + fileHandling: { putFileContents } + }) + const initialServerContent = wrapper.vm.serverContent + wrapper.vm.setCurrentContent('local edits') + await nextTick() + + await wrapper.vm.save() + + expect(wrapper.vm.serverContent).toBe(initialServerContent) + 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: pureEditor(), + fileHandling: { 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: pureEditor(), + fileHandling: { 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: viewerWithUrl(), 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('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: pureEditor() }) + wrapper.vm.setCurrentContent('typed') + await nextTick() + // 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 serverContent, dirty=false again. + wrapper.vm.setCurrentContent(wrapper.vm.serverContent) + await nextTick() + expect(remove.mock.calls.some(([type]) => type === 'beforeunload')).toBe(true) + + add.mockRestore() + remove.mockRestore() + }) + }) +}) 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)
+ {{ $gettext('No preview available for this file.') }} +