diff --git a/changelog/unreleased/change-registering-app-file-editors b/changelog/unreleased/change-registering-app-file-editors new file mode 100644 index 00000000000..70665897d06 --- /dev/null +++ b/changelog/unreleased/change-registering-app-file-editors @@ -0,0 +1,6 @@ +Change: Registering app file editors + +BREAKING CHANGE for developers: The `announceExtensions` method inside the app's `ready` hook, which could be used to register file editors, has been removed. Developers should use the `extensions` property inside the `appInfo` object instead. + +https://github.com/owncloud/web/pull/10330 +https://github.com/owncloud/web/issues/10210 diff --git a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue index 559045dc8b5..8d1842e69c1 100644 --- a/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue +++ b/packages/web-app-files/src/components/AppBar/CreateAndUpload.vue @@ -190,6 +190,7 @@ import { mapActions, mapGetters } from 'vuex' import { isLocationPublicActive, isLocationSpacesActive, + useAppsStore, useCapabilityStore, useFileActions, useFileActionsCreateNewShortcut, @@ -274,6 +275,9 @@ export default defineComponent({ const language = useGettext() const areFileExtensionsShown = computed(() => unref(store.state.Files.areFileExtensionsShown)) + const appsStore = useAppsStore() + const { newFileHandlers } = storeToRefs(appsStore) + useUpload({ uppyService }) if (!uppyService.getPlugin('HandleUpload')) { @@ -306,12 +310,10 @@ export default defineComponent({ const createNewShortcutAction = computed(() => unref(createNewShortcut)[0].handler) - const newFileHandlers = computed(() => store.getters.newFileHandlers) - const { actions: createNewFileActions } = useFileActionsCreateNewFile({ store, space: props.space, - newFileHandlers: newFileHandlers + newFileHandlers }) const mimetypesAllowedForCreation = computed(() => { @@ -432,6 +434,7 @@ export default defineComponent({ return { ...useFileActions({ store }), ...useRequest(), + newFileHandlers, clientService, isPublicLocation: useActiveLocation(isLocationPublicActive, 'files-public-link'), isSpacesGenericLocation: useActiveLocation(isLocationSpacesActive, 'files-spaces-generic'), @@ -457,7 +460,7 @@ export default defineComponent({ } }, computed: { - ...mapGetters(['configuration', 'newFileHandlers']), + ...mapGetters(['configuration']), ...mapGetters('Files', ['files', 'selectedFiles', 'clipboardResources']), ...mapGetters('runtime/ancestorMetaData', ['ancestorMetaData']), diff --git a/packages/web-app-files/tests/__fixtures__/fileActions.ts b/packages/web-app-files/tests/__fixtures__/fileActions.ts index 5167d7d1ebb..ccf3ad7df1b 100644 --- a/packages/web-app-files/tests/__fixtures__/fileActions.ts +++ b/packages/web-app-files/tests/__fixtures__/fileActions.ts @@ -1,26 +1,3 @@ -export const meta = { - files: { - name: 'Files', - id: 'files', - icon: 'folder' - }, - preview: { - name: 'Preview', - id: 'preview', - icon: 'image' - }, - 'draw-io': { - name: 'Draw.io', - id: 'draw-io', - icon: 'grid' - }, - 'text-editor': { - name: 'Text Editor', - id: 'text-editor', - icon: 'file-text' - } -} - const routes = [ 'files-personal', 'files-favorites', @@ -56,17 +33,6 @@ export const editors = [ } ] -export const apps = { - customFileListIndicators: [], - file: { - edit: false, - path: '' - }, - fileEditors: editors, - newFileHandlers: editors, - meta -} - export const fileActions = { download: { name: 'download-file', diff --git a/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts b/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts index a910fa656ef..2626b001023 100644 --- a/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts +++ b/packages/web-app-files/tests/unit/components/AppBar/CreateAndUpload.spec.ts @@ -207,14 +207,8 @@ function getWrapper({ }) ) - const storeOptions = { - ...defaultStoreMockOptions, - getters: { - ...defaultStoreMockOptions.getters, - newFileHandlers: () => newFileHandlers, - user: () => ({ id: '1' }) - } - } + const storeOptions = { ...defaultStoreMockOptions } + storeOptions.getters.apps.mockImplementation(() => ({ fileEditors: [] })) @@ -245,6 +239,7 @@ function getWrapper({ plugins: [ ...defaultPlugins({ piniaOptions: { + appsState: { newFileHandlers }, spacesState: { spaces: spaces as any }, capabilityState: { capabilities } } diff --git a/packages/web-app-files/tests/unit/components/SideBar/Actions/FileActions.spec.ts b/packages/web-app-files/tests/unit/components/SideBar/Actions/FileActions.spec.ts index f34ccacd33c..c945885aa74 100644 --- a/packages/web-app-files/tests/unit/components/SideBar/Actions/FileActions.spec.ts +++ b/packages/web-app-files/tests/unit/components/SideBar/Actions/FileActions.spec.ts @@ -1,5 +1,5 @@ import FileActions from 'web-app-files/src/components/SideBar/Actions/FileActions.vue' -import { fileActions, editors, meta } from 'web-app-files/tests/__fixtures__/fileActions' +import { fileActions } from 'web-app-files/tests/__fixtures__/fileActions' import { Resource, SpaceResource } from '@ownclouders/web-client/src/helpers' import { mock } from 'jest-mock-extended' import { @@ -58,8 +58,6 @@ describe('FileActions', () => { function getWrapper() { const storeOptions = { ...defaultStoreMockOptions } storeOptions.modules.Files.state.currentFolder = { path: '' } - storeOptions.modules.apps.state.fileEditors = editors - storeOptions.modules.apps.state.meta = meta const store = createStore(storeOptions) return { wrapper: mount(FileActions, { diff --git a/packages/web-app-preview/src/App.vue b/packages/web-app-preview/src/App.vue index a01ae1f9e4b..a974f82c081 100644 --- a/packages/web-app-preview/src/App.vue +++ b/packages/web-app-preview/src/App.vue @@ -89,6 +89,7 @@ import { AppTopBar, FileSideBar, ProcessorType, + useAppsStore, useSelectedResources, useSideBar } from '@ownclouders/web-pkg' @@ -111,26 +112,10 @@ import { CachedFile } from './helpers/types' import AppBanner from '@ownclouders/web-pkg/src/components/AppBanner.vue' import { watch } from 'vue' import { getCurrentInstance } from 'vue' +import { getMimeTypes } from './mimeTypes' export const appId = 'preview' -export const mimeTypes = () => { - return [ - 'audio/flac', - 'audio/mpeg', - 'audio/ogg', - 'audio/wav', - 'audio/x-flac', - 'audio/x-wav', - 'image/gif', - 'image/jpeg', - 'image/png', - 'video/mp4', - 'video/webm', - ...((window as any).__$store?.getters.extensionConfigByAppId(appId).mimeTypes || []) - ] -} - export default defineComponent({ name: 'Preview', components: { @@ -145,6 +130,7 @@ export default defineComponent({ setup() { const router = useRouter() const route = useRoute() + const appsStore = useAppsStore() const appDefaults = useAppDefaults({ applicationId: 'preview' }) const contextRouteQuery = useRouteQuery('contextRouteQuery') const { downloadFile } = useDownloadFile() @@ -153,6 +139,10 @@ export default defineComponent({ const cachedFiles = ref([]) const folderLoaded = ref(false) + const mimeTypes = computed(() => { + return getMimeTypes(appsStore.getExternalAppConfigByAppId(appId)?.mimeTypes) + }) + const sortBy = computed(() => { if (!unref(contextRouteQuery)) { return 'name' @@ -189,7 +179,7 @@ export default defineComponent({ } const files = unref(activeFiles).filter((file) => { - return mimeTypes().includes(file.mimeType?.toLowerCase()) + return unref(mimeTypes).includes(file.mimeType?.toLowerCase()) }) return sortHelper(files, [{ name: unref(sortBy) }], unref(sortBy), unref(sortDir)) diff --git a/packages/web-app-preview/src/index.ts b/packages/web-app-preview/src/index.ts index 319b1040982..89883added4 100644 --- a/packages/web-app-preview/src/index.ts +++ b/packages/web-app-preview/src/index.ts @@ -1,41 +1,48 @@ +import { defineWebApplication, useAppsStore } from '@ownclouders/web-pkg' import translations from '../l10n/translations.json' import * as app from './App.vue' -const { default: App, mimeTypes, appId } = app as any +import { useGettext } from 'vue3-gettext' +import { getMimeTypes } from './mimeTypes' -// just a dummy function to trick gettext tools -function $gettext(msg) { - return msg -} +const { default: App, appId } = app as any -const routes = [ - { - path: '/:driveAliasAndItem(.*)?', - component: App, - name: 'media', - meta: { - authContext: 'hybrid', - title: $gettext('Preview'), - patchCleanPath: true - } - } -] +export default defineWebApplication({ + setup() { + const { $gettext } = useGettext() + const appsStore = useAppsStore() + + const routes = [ + { + path: '/:driveAliasAndItem(.*)?', + component: App, + name: 'media', + meta: { + authContext: 'hybrid', + title: $gettext('Preview'), + patchCleanPath: true + } + } + ] -const routeName = 'preview-media' + const routeName = 'preview-media' -const appInfo = { - name: $gettext('Preview'), - id: appId, - icon: 'eye', - extensions: mimeTypes().map((mimeType) => ({ - mimeType, - routeName, - label: $gettext('Preview') - })) -} + const appInfo = { + name: $gettext('Preview'), + id: appId, + icon: 'eye', + extensions: getMimeTypes(appsStore.getExternalAppConfigByAppId(appId)?.mimeTypes).map( + (mimeType) => ({ + mimeType, + routeName, + label: $gettext('Preview') + }) + ) + } -export default { - appInfo, - routes, - translations, - mimeTypes -} + return { + appInfo, + routes, + translations + } + } +}) diff --git a/packages/web-app-preview/src/mimeTypes.ts b/packages/web-app-preview/src/mimeTypes.ts new file mode 100644 index 00000000000..72aa0680cd9 --- /dev/null +++ b/packages/web-app-preview/src/mimeTypes.ts @@ -0,0 +1,16 @@ +export const getMimeTypes = (extensionMimeTypes: string[] = []) => { + return [ + 'audio/flac', + 'audio/mpeg', + 'audio/ogg', + 'audio/wav', + 'audio/x-flac', + 'audio/x-wav', + 'image/gif', + 'image/jpeg', + 'image/png', + 'video/mp4', + 'video/webm', + ...extensionMimeTypes + ] +} diff --git a/packages/web-app-text-editor/src/index.ts b/packages/web-app-text-editor/src/index.ts index cfef8aec6ba..cbbac020789 100644 --- a/packages/web-app-text-editor/src/index.ts +++ b/packages/web-app-text-editor/src/index.ts @@ -1,12 +1,18 @@ import { useGettext } from 'vue3-gettext' import translations from '../l10n/translations.json' import TextEditor from './App.vue' -import { AppWrapperRoute, defineWebApplication, useUserStore } from '@ownclouders/web-pkg' +import { + AppWrapperRoute, + defineWebApplication, + useAppsStore, + useUserStore +} from '@ownclouders/web-pkg' export default defineWebApplication({ setup() { const { $gettext } = useGettext() const userStore = useUserStore() + const appsStore = useAppsStore() const appId = 'text-editor' @@ -50,11 +56,13 @@ export default defineWebApplication({ } ] - const config = (window as any).__$store.getters.extensionConfigByAppId(appId) + const config = appsStore.getExternalAppConfigByAppId(appId) extensions.push(...(config.extraExtensions || []).map((ext) => ({ extension: ext }))) - let primaryExtensions = (window as any).__$store.getters.extensionConfigByAppId(appId) - .primaryExtensions || ['txt', 'md'] + let primaryExtensions = appsStore.getExternalAppConfigByAppId(appId).primaryExtensions || [ + 'txt', + 'md' + ] if (typeof primaryExtensions === 'string') { primaryExtensions = [primaryExtensions] diff --git a/packages/web-pkg/src/apps/types.ts b/packages/web-pkg/src/apps/types.ts index 0b43aa4d826..1b5054be1b0 100644 --- a/packages/web-pkg/src/apps/types.ts +++ b/packages/web-pkg/src/apps/types.ts @@ -2,9 +2,9 @@ import { App, ComponentCustomProperties, Ref } from 'vue' import { RouteLocationRaw, Router, RouteRecordRaw } from 'vue-router' import { Module, Store } from 'vuex' import { Extension } from '../composables/piniaStores' +import { IconFillType } from '../helpers' export interface AppReadyHookArgs { - announceExtension: (extension: { [key: string]: unknown }) => void globalProperties: ComponentCustomProperties & Record router: Router store: Store @@ -42,18 +42,44 @@ export interface ApplicationQuickAction { export type AppConfigObject = Record export interface ApplicationMenuItem { - enabled: () => boolean - priority: number + enabled?: () => boolean + priority?: number openAsEditor?: boolean } +export interface ApplicationNewFileHandler { + action?: ApplicationFileEditor + ext?: string + menuTitle?: () => string + routes?: RouteLocationRaw[] +} + +export interface ApplicationFileEditor { + app?: string + extension?: string + handler?: () => Promise | void + hasPriority?: boolean + label?: string + mimeType?: string + routeName?: string +} + +export interface ApplicationInformationExtension extends ApplicationFileEditor { + newFileMenu?: ApplicationNewFileHandler + routes?: RouteLocationRaw[] +} + /** ApplicationInformation describes required information of an application */ export interface ApplicationInformation { + color?: string id?: string name?: string icon?: string + iconFillType?: IconFillType + iconColor?: string + img?: string isFileEditor?: boolean - extensions?: any[] + extensions?: ApplicationInformationExtension[] defaultExtension?: string applicationMenu?: ApplicationMenuItem } diff --git a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue index 8a980804ab4..5d1df3496aa 100644 --- a/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue +++ b/packages/web-pkg/src/components/AppTemplates/AppWrapper.vue @@ -54,7 +54,8 @@ import { useSideBar, useModals, useMessages, - useSpacesStore + useSpacesStore, + useAppsStore } from '../../composables' import { Action, @@ -106,6 +107,7 @@ export default defineComponent({ setup(props) { const { $gettext } = useGettext() const store = useStore() + const appsStore = useAppsStore() const { showMessage, showErrorMessage } = useMessages() const router = useRouter() const currentRoute = useRoute() @@ -166,7 +168,7 @@ export default defineComponent({ applicationId: props.applicationId }) - const { applicationMeta } = useAppMeta({ applicationId: props.applicationId, store }) + const { applicationMeta } = useAppMeta({ applicationId: props.applicationId, appsStore }) const pageTitle = computed(() => { const { name: appName } = unref(applicationMeta) diff --git a/packages/web-pkg/src/composables/actions/files/useFileActions.ts b/packages/web-pkg/src/composables/actions/files/useFileActions.ts index 984ef388e41..7dc9acfc768 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActions.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActions.ts @@ -33,7 +33,7 @@ import { useFileActionsRestore, useFileActionsCreateSpaceFromResource } from './index' -import { useCapabilityStore } from '../../piniaStores' +import { useAppsStore, useCapabilityStore } from '../../piniaStores' export const EDITOR_MODE_EDIT = 'edit' export const EDITOR_MODE_CREATE = 'create' @@ -44,6 +44,7 @@ export interface GetFileActionsOptions extends FileActionOptions { export const useFileActions = ({ store }: { store?: Store } = {}) => { store = store || useStore() + const appsStore = useAppsStore() const capabilityStore = useCapabilityStore() const router = useRouter() const { $gettext } = useGettext() @@ -87,22 +88,21 @@ export const useFileActions = ({ store }: { store?: Store } = {}) => { ]) const editorActions = computed(() => { - const apps = store.state.apps - return (apps.fileEditors as any[]) + return appsStore.fileEditors .map((editor): FileAction => { return { - name: `editor-${editor.id}`, + name: `editor-${editor.app}`, label: () => { if (editor.label) { return $gettext(editor.label) } - return $gettext('Open in %{app}', { app: apps.meta[editor.app].name }, true) + return $gettext('Open in %{app}', { app: appsStore.apps[editor.app].name }, true) }, - icon: apps.meta[editor.app].icon, - ...(apps.meta[editor.app].iconFillType && { - iconFillType: apps.meta[editor.app].iconFillType + icon: appsStore.apps[editor.app].icon, + ...(appsStore.apps[editor.app].iconFillType && { + iconFillType: appsStore.apps[editor.app].iconFillType }), - img: apps.meta[editor.app].img, + img: appsStore.apps[editor.app].img, handler: (options) => openEditor( editor, @@ -141,7 +141,9 @@ export const useFileActions = ({ store }: { store?: Store } = {}) => { }, hasPriority: editor.hasPriority, componentType: 'button', - class: `oc-files-actions-${kebabCase(apps.meta[editor.app].name).toLowerCase()}-trigger` + class: `oc-files-actions-${kebabCase( + appsStore.apps[editor.app].name + ).toLowerCase()}-trigger` } }) .sort((first, second) => { diff --git a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts index a9be516e0b8..2afea13f087 100644 --- a/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts +++ b/packages/web-pkg/src/composables/actions/files/useFileActionsCreateNewFile.ts @@ -22,6 +22,7 @@ import { configurationManager } from '../../../configuration' import { stringify } from 'qs' import { AncestorMetaData } from '../../../types' import { useMessages, useModals, useUserStore, useCapabilityStore } from '../../piniaStores' +import { ApplicationNewFileHandler } from '../../../apps' export const useFileActionsCreateNewFile = ({ store, @@ -31,7 +32,7 @@ export const useFileActionsCreateNewFile = ({ }: { store?: Store space?: SpaceResource - newFileHandlers?: Ref // FIXME: type? + newFileHandlers?: Ref mimetypesAllowedForCreation?: Ref // FIXME: type? } = {}) => { store = store || useStore() @@ -214,7 +215,7 @@ export const useFileActionsCreateNewFile = ({ name: 'create-new-file', icon: 'add', handler: (args) => handler(args, newFileHandler.ext, openAction), - label: () => newFileHandler.menuTitle($gettext), + label: () => newFileHandler.menuTitle(), isEnabled: () => { return unref(currentFolder)?.canUpload({ user: userStore.user }) }, diff --git a/packages/web-pkg/src/composables/appDefaults/useAppConfig.ts b/packages/web-pkg/src/composables/appDefaults/useAppConfig.ts index 78a0351f5ff..e0e4f3cf8c9 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppConfig.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppConfig.ts @@ -1,10 +1,9 @@ -import { Store } from 'vuex' -import { computed, Ref, unref } from 'vue' -import { useAppMeta } from './useAppMeta' +import { computed, Ref } from 'vue' import type { AppConfigObject } from '../../apps' +import { AppsStore } from '../piniaStores' export interface AppConfigOptions { - store: Store + appsStore: AppsStore applicationId: string } @@ -13,9 +12,8 @@ export interface AppConfigResult { } export function useAppConfig(options: AppConfigOptions): AppConfigResult { - const applicationMetaResult = useAppMeta(options) - const applicationConfig = computed( - () => unref(applicationMetaResult.applicationMeta).config || {} + const applicationConfig = computed(() => + options.appsStore.getExternalAppConfigByAppId(options.applicationId) ) return { diff --git a/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts b/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts index 53e5fcceff2..a70af15714b 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppDefaults.ts @@ -21,7 +21,7 @@ import { useClientService } from '../clientService' import { MaybeRef } from '../../utils' import { useDriveResolver } from '../driveResolver' import { urlJoin } from '@ownclouders/web-client/src/utils' -import { useAuthStore } from '../piniaStores' +import { useAppsStore, useAuthStore } from '../piniaStores' import { storeToRefs } from 'pinia' // TODO: this file/folder contains file/folder loading logic extracted from preview and drawio extensions @@ -46,6 +46,7 @@ export type AppDefaultsResult = AppConfigResult & export function useAppDefaults(options: AppDefaultsOptions): AppDefaultsResult { const router = useRouter() const store = useStore() + const appsStore = useAppsStore() const currentRoute = useRoute() const clientService = options.clientService ?? useClientService() const applicationId = options.applicationId @@ -80,7 +81,7 @@ export function useAppDefaults(options: AppDefaultsOptions): AppDefaultsResult { }) useAppDocumentTitle({ - store, + appsStore, applicationId, applicationName: options.applicationName, currentFileContext, @@ -90,7 +91,7 @@ export function useAppDefaults(options: AppDefaultsOptions): AppDefaultsResult { return { isPublicLinkContext: publicLinkContextReady, currentFileContext, - ...useAppConfig({ store, ...options }), + ...useAppConfig({ appsStore, ...options }), ...useAppNavigation({ router, currentFileContext }), ...useAppFileHandling({ clientService diff --git a/packages/web-pkg/src/composables/appDefaults/useAppDocumentTitle.ts b/packages/web-pkg/src/composables/appDefaults/useAppDocumentTitle.ts index 2c0fcec0941..d2079258d9a 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppDocumentTitle.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppDocumentTitle.ts @@ -3,13 +3,13 @@ import { basename } from 'path' import { FileContext } from './types' import { useAppMeta } from './useAppMeta' import { useDocumentTitle } from './useDocumentTitle' -import { Store } from 'vuex' import { RouteLocationNormalizedLoaded } from 'vue-router' import { MaybeRef } from 'vue' import { useGettext } from 'vue3-gettext' +import { AppsStore } from '../piniaStores' interface AppDocumentTitleOptions { - store: Store + appsStore: AppsStore applicationId: string applicationName?: MaybeRef currentFileContext: Ref @@ -17,13 +17,13 @@ interface AppDocumentTitleOptions { } export function useAppDocumentTitle({ - store, + appsStore, applicationId, applicationName, currentFileContext, currentRoute }: AppDocumentTitleOptions): void { - const appMeta = useAppMeta({ applicationId, store }) + const appMeta = useAppMeta({ applicationId, appsStore }) const { $gettext } = useGettext() const titleSegments = computed(() => { diff --git a/packages/web-pkg/src/composables/appDefaults/useAppMeta.ts b/packages/web-pkg/src/composables/appDefaults/useAppMeta.ts index d024452f4f4..96cf41e367c 100644 --- a/packages/web-pkg/src/composables/appDefaults/useAppMeta.ts +++ b/packages/web-pkg/src/composables/appDefaults/useAppMeta.ts @@ -1,33 +1,18 @@ -import { Store } from 'vuex' -import { computed, Ref } from 'vue' -import type { AppConfigObject } from '../../apps' +import { computed } from 'vue' +import { AppsStore } from '../piniaStores' interface AppMetaOptions { - store: Store + appsStore: AppsStore applicationId: string } -export interface AppMetaObject { - config: AppConfigObject - theme: string - url: string - icon: string - id: string - img: string - name: string -} - -export interface AppMetaResult { - applicationMeta: Ref -} - -export function useAppMeta({ store, applicationId }: AppMetaOptions): AppMetaResult { +export function useAppMeta({ appsStore, applicationId }: AppMetaOptions) { const applicationMeta = computed(() => { - const editor = store.getters.apps[applicationId] - if (!editor) { + const app = appsStore.apps[applicationId] + if (!app) { throw new Error(`useAppConfig: could not find config for applicationId: ${applicationId}`) } - return editor || {} + return app || {} }) return { diff --git a/packages/web-pkg/src/composables/piniaStores/apps.ts b/packages/web-pkg/src/composables/piniaStores/apps.ts new file mode 100644 index 00000000000..746983c176d --- /dev/null +++ b/packages/web-pkg/src/composables/piniaStores/apps.ts @@ -0,0 +1,89 @@ +import { defineStore } from 'pinia' +import { computed, ref, unref } from 'vue' +import { + AppConfigObject, + ApplicationFileEditor, + ApplicationInformation, + ApplicationInformationExtension, + ApplicationNewFileHandler +} from '../../apps' + +export const useAppsStore = defineStore('apps', () => { + const apps = ref>({}) + const newFileHandlers = ref([]) + const fileEditors = ref([]) + const externalAppConfigs = ref>({}) + + const appIds = computed(() => Object.keys(unref(apps))) + + const registerApp = (appInfo: ApplicationInformation) => { + if (!appInfo.id) { + return + } + + if (appInfo.extensions) { + appInfo.extensions.forEach((extension) => { + registerFileEditor({ appId: appInfo.id, data: extension }) + }) + } + + unref(apps)[appInfo.id] = { + applicationMenu: appInfo.applicationMenu || {}, + defaultExtension: appInfo.defaultExtension || '', + icon: 'check_box_outline_blank', + name: appInfo.name || appInfo.id, + ...appInfo + } + } + + const registerFileEditor = ({ + appId, + data + }: { + appId: string + data: ApplicationInformationExtension + }) => { + const editor = { + app: appId, + extension: data.extension, + handler: data.handler, + label: data.label, + mimeType: data.mimeType, + routeName: data.routeName, + hasPriority: + data.hasPriority || + unref(externalAppConfigs)?.[appId]?.priorityExtensions?.includes(data.extension) || + false + } satisfies ApplicationFileEditor + + unref(fileEditors).push(editor) + + if (data.newFileMenu) { + data.newFileMenu.action = editor + data.newFileMenu.ext = data.extension + data.newFileMenu.routes = data.routes + unref(newFileHandlers).push(data.newFileMenu) + } + } + + const loadExternalAppConfig = ({ appId, config }: { appId: string; config: AppConfigObject }) => { + externalAppConfigs.value = { ...unref(externalAppConfigs), [appId]: config } + } + + const getExternalAppConfigByAppId = (appId: string) => { + return unref(externalAppConfigs)[appId] || {} + } + + return { + apps, + appIds, + fileEditors, + newFileHandlers, + + registerApp, + loadExternalAppConfig, + getExternalAppConfigByAppId + } +}) + +export type AppsStore = ReturnType diff --git a/packages/web-pkg/src/composables/piniaStores/index.ts b/packages/web-pkg/src/composables/piniaStores/index.ts index de58879d727..eee25f0faf7 100644 --- a/packages/web-pkg/src/composables/piniaStores/index.ts +++ b/packages/web-pkg/src/composables/piniaStores/index.ts @@ -1,3 +1,4 @@ +export * from './apps' export * from './auth' export * from './capabilities' export * from './extensionRegistry' diff --git a/packages/web-pkg/tests/unit/composables/scrollTo/useScrollTo.spec.ts b/packages/web-pkg/tests/unit/composables/scrollTo/useScrollTo.spec.ts index 03c9549003c..d074b97e7aa 100644 --- a/packages/web-pkg/tests/unit/composables/scrollTo/useScrollTo.spec.ts +++ b/packages/web-pkg/tests/unit/composables/scrollTo/useScrollTo.spec.ts @@ -146,8 +146,7 @@ describe('useScrollTo', () => { const mocks = { ...defaultComponentMocks({ currentRoute: mock({ query: { scrollTo: resourceId } }) - }), - $store: store + }) } getComposableWrapper( @@ -174,8 +173,7 @@ describe('useScrollTo', () => { currentRoute: mock({ query: { scrollTo: resourceId, details: 'details' } }) - }), - $store: store + }) } getComposableWrapper( diff --git a/packages/web-runtime/src/container/api.ts b/packages/web-runtime/src/container/api.ts index 728c4a76516..a3ae0095e30 100644 --- a/packages/web-runtime/src/container/api.ts +++ b/packages/web-runtime/src/container/api.ts @@ -79,21 +79,6 @@ const announceNavigationItems = ( }) } -/** - * inject application specific extension into runtime - * - * @param applicationId - * @param store - * @param extension - */ -const announceExtension = ( - applicationId: string, - store: Store, - extension: { [key: string]: unknown } -): void => { - store.commit('REGISTER_EXTENSION', { app: applicationId, extension }) -} - /** * inject application specific translations into runtime * @@ -239,8 +224,6 @@ export const buildRuntimeApi = ({ announceTranslations(supportedLanguages, gettext, appTranslations), announceStore: (applicationStore: Module): void => announceStore(applicationName, store, applicationStore), - announceExtension: (extension: { [key: string]: unknown }): void => - announceExtension(applicationId, store, extension), requestStore: (): Store => requestStore(store), requestRouter: (): Router => requestRouter(router), openPortal: ( diff --git a/packages/web-runtime/src/container/application/classic.ts b/packages/web-runtime/src/container/application/classic.ts index d6e4e656998..352ce3124b9 100644 --- a/packages/web-runtime/src/container/application/classic.ts +++ b/packages/web-runtime/src/container/application/classic.ts @@ -5,7 +5,7 @@ import { isFunction, isObject } from 'lodash-es' import { NextApplication } from './next' import { Store } from 'vuex' import { Router } from 'vue-router' -import { ConfigurationManager, RuntimeError } from '@ownclouders/web-pkg' +import { ConfigurationManager, RuntimeError, useAppsStore } from '@ownclouders/web-pkg' import { AppConfigObject, AppReadyHookArgs, ClassicApplicationScript } from '@ownclouders/web-pkg' import { useExtensionRegistry } from '@ownclouders/web-pkg' import type { Language } from 'vue3-gettext' @@ -61,7 +61,6 @@ class ClassicApplication extends NextApplication { instance, store: this.runtimeApi.requestStore(), router: this.runtimeApi.requestRouter(), - announceExtension: this.runtimeApi.announceExtension, globalProperties: this.app.config.globalProperties }) } @@ -76,7 +75,7 @@ class ClassicApplication extends NextApplication { * @param translations * @param supportedLanguages */ -export const convertClassicApplication = async ({ +export const convertClassicApplication = ({ app, applicationScript, applicationConfig, @@ -94,7 +93,7 @@ export const convertClassicApplication = async ({ gettext: Language supportedLanguages: { [key: string]: string } configurationManager: ConfigurationManager -}): Promise => { +}): NextApplication => { if (applicationScript.setup) { applicationScript = app.runWithContext(() => { return applicationScript.setup({ @@ -128,7 +127,8 @@ export const convertClassicApplication = async ({ supportedLanguages }) - await store.dispatch('registerApp', applicationScript.appInfo) + const appsStore = useAppsStore() + appsStore.registerApp(applicationScript.appInfo) if (applicationScript.extensions) { useExtensionRegistry({ configurationManager }).registerExtensions(applicationScript.extensions) diff --git a/packages/web-runtime/src/container/application/index.ts b/packages/web-runtime/src/container/application/index.ts index 4fb2206f25e..7a3d16c011d 100644 --- a/packages/web-runtime/src/container/application/index.ts +++ b/packages/web-runtime/src/container/application/index.ts @@ -122,7 +122,7 @@ export const buildApplication = async ({ if (!isObject(applicationScript.appInfo) && !applicationScript.setup) { throw new RuntimeError('next applications not implemented yet, stay tuned') } else { - application = await convertClassicApplication({ + application = convertClassicApplication({ app, applicationScript, applicationConfig, @@ -131,7 +131,7 @@ export const buildApplication = async ({ gettext, supportedLanguages, configurationManager - }).catch() + }) } } catch (err) { throw new RuntimeError('cannot create application', err.message, applicationPath) diff --git a/packages/web-runtime/src/container/bootstrap.ts b/packages/web-runtime/src/container/bootstrap.ts index 2139acfc58f..293a14f230e 100644 --- a/packages/web-runtime/src/container/bootstrap.ts +++ b/packages/web-runtime/src/container/bootstrap.ts @@ -18,7 +18,9 @@ import { useAuthStore, AuthStore, useCapabilityStore, - CapabilityStore + CapabilityStore, + useAppsStore, + AppsStore } from '@ownclouders/web-pkg' import { authService } from '../services/auth' import { @@ -120,24 +122,14 @@ export const announceConfiguration = async (path: string): Promise => { const store = new Store({ ...storeOptions }) - await store.dispatch('loadConfig', runtimeConfiguration) - - /** - * TODO: Find a different way to access store from within JS files - * potential options are: - * - use the api which already is in place but deprecated - * - use a global object - * - * at the moment it is not clear if this api should be exposed or not. - * we need to decide if we extend the api more or just expose the store and de deprecate - * the apis for retrieving it. - */ - ;(window as any).__$store = store + await store.dispatch('loadConfig', { config: runtimeConfiguration, appsStore }) return store } @@ -243,23 +235,22 @@ export const initializeApplications = async ({ */ export const announceApplicationsReady = async ({ app, - store, + appsStore, applications }: { app: App - store: Store + appsStore: AppsStore applications: NextApplication[] }): Promise => { await Promise.all(applications.map((application) => application.ready())) - const apps = store.state.apps const mapping: ResourceIconMapping = { mimeType: {}, extension: {} } - ;(apps.fileEditors as any[]).forEach((editor) => { - const meta = apps.meta[editor.app] + appsStore.fileEditors.forEach((editor) => { + const meta = appsStore.apps[editor.app] const getIconDefinition = () => { return { @@ -342,13 +333,23 @@ export const announceTheme = async ({ } export const announcePiniaStores = () => { + const appsStore = useAppsStore() const authStore = useAuthStore() const capabilityStore = useCapabilityStore() const messagesStore = useMessages() const modalStore = useModals() const spacesStore = useSpacesStore() const userStore = useUserStore() - return { authStore, capabilityStore, messagesStore, modalStore, spacesStore, userStore } + + return { + appsStore, + authStore, + capabilityStore, + messagesStore, + modalStore, + spacesStore, + userStore + } } /** @@ -539,13 +540,15 @@ export const announcePasswordPolicyService = ({ app }: { app: App }): void => { */ export const announceDefaults = ({ store, - router + router, + appsStore }: { store: Store router: Router + appsStore: AppsStore }): void => { // set home route - const appIds = store.getters.appIds + const appIds = appsStore.appIds let defaultExtensionId = store.getters.configuration.options.defaultExtension if (!defaultExtensionId || appIds.indexOf(defaultExtensionId) < 0) { defaultExtensionId = appIds[0] diff --git a/packages/web-runtime/src/container/types.ts b/packages/web-runtime/src/container/types.ts index b13497becd8..e60fd32bcbd 100644 --- a/packages/web-runtime/src/container/types.ts +++ b/packages/web-runtime/src/container/types.ts @@ -12,7 +12,6 @@ export interface RuntimeApi { announceNavigationItems: (navigationItems: AppNavigationItem[]) => void announceTranslations: (appTranslations: ApplicationTranslations) => void announceStore: (applicationStore: Module) => void - announceExtension: (extension: { [key: string]: unknown }) => void requestStore: () => Store requestRouter: () => Router openPortal: ( diff --git a/packages/web-runtime/src/index.ts b/packages/web-runtime/src/index.ts index 3d63a74fc81..928d0c6359c 100644 --- a/packages/web-runtime/src/index.ts +++ b/packages/web-runtime/src/index.ts @@ -50,15 +50,14 @@ export const bootstrapApp = async (configurationPath: string): Promise => const app = createApp(pages.success) app.use(pinia) - const { authStore, capabilityStore, spacesStore, userStore } = announcePiniaStores() + const { appsStore, authStore, capabilityStore, spacesStore, userStore } = announcePiniaStores() app.provide('$router', router) const runtimeConfiguration = await announceConfiguration(configurationPath) startSentry(runtimeConfiguration, app) - const store = await announceStore({ runtimeConfiguration }) - app.provide('$store', store) + const store = await announceStore({ runtimeConfiguration, appsStore }) app.provide('store', store) app.use(abilitiesPlugin, createMongoAbility([]), { useGlobalProperties: true }) @@ -145,7 +144,7 @@ export const bootstrapApp = async (configurationPath: string): Promise => }) announceCustomStyles({ runtimeConfiguration }) announceCustomScripts({ runtimeConfiguration }) - announceDefaults({ store, router }) + announceDefaults({ store, appsStore, router }) app.use(router) app.use(store) @@ -169,7 +168,7 @@ export const bootstrapApp = async (configurationPath: string): Promise => return } announceVersions({ capabilityStore }) - await announceApplicationsReady({ app, store, applications }) + await announceApplicationsReady({ app, appsStore, applications }) }, { immediate: true @@ -260,7 +259,8 @@ export const bootstrapApp = async (configurationPath: string): Promise => } export const bootstrapErrorApp = async (err: Error): Promise => { - const store = await announceStore({ runtimeConfiguration: {} }) + const { appsStore } = announcePiniaStores() + const store = await announceStore({ runtimeConfiguration: {}, appsStore }) const { capabilityStore } = announcePiniaStores() announceVersions({ capabilityStore }) const app = createApp(pages.failure) diff --git a/packages/web-runtime/src/layouts/Application.vue b/packages/web-runtime/src/layouts/Application.vue index 0ffeae8689f..71986388165 100644 --- a/packages/web-runtime/src/layouts/Application.vue +++ b/packages/web-runtime/src/layouts/Application.vue @@ -39,6 +39,7 @@ import orderBy from 'lodash-es/orderBy' import { AppLoadingSpinner, SidebarNavExtension, + useAppsStore, useAuthStore, useExtensionRegistry } from '@ownclouders/web-pkg' @@ -62,6 +63,7 @@ import { useGettext } from 'vue3-gettext' import '@uppy/core/dist/style.min.css' import { AppNavigationItem } from '@ownclouders/web-pkg' +import { storeToRefs } from 'pinia' const MOBILE_BREAKPOINT = 640 @@ -85,6 +87,9 @@ export default defineComponent({ const activeApp = useActiveApp() const extensionRegistry = useExtensionRegistry() + const appsStore = useAppsStore() + const { apps } = storeToRefs(appsStore) + const extensionNavItems = computed(() => extensionRegistry .requestExtensions('sidebarNav', [ @@ -174,6 +179,7 @@ export default defineComponent({ }) return { + apps, isSidebarVisible, isLoading, navItems, @@ -182,7 +188,7 @@ export default defineComponent({ } }, computed: { - ...mapGetters(['apps', 'configuration']), + ...mapGetters(['configuration']), isIE11() { return !!(window as any).MSInputMethodContext && !!(document as any).documentMode }, @@ -195,7 +201,7 @@ export default defineComponent({ applicationsList() { const list = [] - Object.values(this.apps).forEach((app: any) => { + Object.values(this.apps).forEach((app) => { list.push({ ...app, type: 'extension' diff --git a/packages/web-runtime/src/store/apps.ts b/packages/web-runtime/src/store/apps.ts deleted file mode 100644 index 8f29ffca103..00000000000 --- a/packages/web-runtime/src/store/apps.ts +++ /dev/null @@ -1,113 +0,0 @@ -const state = { - fileEditors: [], - fileEditorConfigs: {}, - newFileHandlers: [], - customFilesListIndicators: [], - meta: {} -} - -const actions = { - registerApp({ commit }, app) { - commit('REGISTER_APP', app) - }, - addFileAction({ commit }, action) { - commit('ADD_FILE_ACTION', action) - } -} - -const mutations = { - REGISTER_EXTENSION(state, extensionBundle) { - const { app, extension } = extensionBundle - const editor = { - app, - icon: extension.icon, - img: extension.img, - color: extension.color, - routeName: extension.routeName, - routes: extension.routes || [], - extension: extension.extension, - mimeType: extension.mimeType, - handler: extension.handler, - hasPriority: - extension.hasPriority || - state.fileEditorConfigs?.[app]?.priorityExtensions?.includes(extension.extension) || - false, - config: (state.fileEditorConfigs || {})[app], - ...(extension.label && { label: extension.label }) - } - - state.fileEditors.push(editor) - - if (extension.newFileMenu) { - extension.newFileMenu.ext = extension.extension - extension.newFileMenu.action = editor - extension.newFileMenu.routes = extension.routes - state.newFileHandlers.push(extension.newFileMenu) - } - }, - REGISTER_APP(state, appInfo) { - if (appInfo.extensions) { - appInfo.extensions.forEach((extension) => { - ;(this as any).commit('REGISTER_EXTENSION', { - app: appInfo.id, - extension - }) - }) - } - - if (appInfo.filesListIndicators) { - const indicators = state.customFilesListIndicators - appInfo.filesListIndicators.forEach((indicator) => { - indicators.push(indicator) - }) - state.customFilesListIndicators = indicators - } - - if (!appInfo.id) { - return - } - // name: use id as fallback display name - // icon: use empty box as fallback icon - const app = { - name: appInfo.name || appInfo.id, - id: appInfo.id, - icon: appInfo.icon || 'check_box_outline_blank', - ...(appInfo.iconFillType && { iconFillType: appInfo.iconFillType }), - ...(appInfo.iconColor && { iconColor: appInfo.iconColor }), - img: appInfo.img || null, - config: (state.fileEditorConfigs || {})[appInfo.id], - color: appInfo.color || '', - applicationMenu: appInfo.applicationMenu || {}, - defaultExtension: appInfo.defaultExtension || '' - } - state.meta[app.id] = app - }, - LOAD_EXTENSION_CONFIG(state, { id, config }) { - const fileEditorConfigs = { ...state.fileEditorConfigs } - fileEditorConfigs[id] = config - state.fileEditorConfigs = fileEditorConfigs - } -} - -const getters = { - appIds: (state) => { - return Object.keys(state.meta) - }, - apps: (state) => { - return state.meta - }, - newFileHandlers: (state) => { - return state.newFileHandlers - }, - customFilesListIndicators: (state) => state.customFilesListIndicators, - extensionConfigByAppId: (state) => (appId) => { - return state.fileEditorConfigs[appId] || {} - } -} - -export default { - state, - actions, - mutations, - getters -} diff --git a/packages/web-runtime/src/store/config.ts b/packages/web-runtime/src/store/config.ts index cc7f63953b4..22e3d214af1 100644 --- a/packages/web-runtime/src/store/config.ts +++ b/packages/web-runtime/src/store/config.ts @@ -53,17 +53,13 @@ const state = { } const actions = { - loadConfig({ commit }, config) { + loadConfig({ commit }, { config, appsStore }) { commit('LOAD_CONFIG', config) if (config.external_apps) { config.external_apps.forEach((externalApp) => { if (externalApp.config !== undefined) { - commit( - 'LOAD_EXTENSION_CONFIG', - { id: externalApp.id, config: externalApp.config }, - { root: true } - ) + appsStore.loadExternalAppConfig({ appId: externalApp.id, config: externalApp.config }) } }) } diff --git a/packages/web-runtime/src/store/index.ts b/packages/web-runtime/src/store/index.ts index fdf04faab03..1378bf477e1 100644 --- a/packages/web-runtime/src/store/index.ts +++ b/packages/web-runtime/src/store/index.ts @@ -1,5 +1,4 @@ import ancestorMetaData from './ancestorMetaData' -import apps from './apps' import config from './config' import navigation from './navigation' @@ -12,7 +11,6 @@ const runtime = { export default { modules: { - apps, config, navigation, runtime diff --git a/packages/web-runtime/tests/unit/components/Topbar/TopBar.spec.ts b/packages/web-runtime/tests/unit/components/Topbar/TopBar.spec.ts index f3d32cde007..29ec2db31b7 100644 --- a/packages/web-runtime/tests/unit/components/Topbar/TopBar.spec.ts +++ b/packages/web-runtime/tests/unit/components/Topbar/TopBar.spec.ts @@ -75,12 +75,7 @@ describe('Top Bar component', () => { const getWrapper = ({ capabilities = {}, isUserContextReady = true } = {}) => { const mocks = { ...defaultComponentMocks() } - const storeOptions = { - ...defaultStoreMockOptions, - getters: { - ...defaultStoreMockOptions.getters - } - } + const storeOptions = { ...defaultStoreMockOptions } storeOptions.getters.configuration.mockImplementation(() => ({ options: { disableFeedbackLink: false } })) diff --git a/packages/web-runtime/tests/unit/container/bootstrap.spec.ts b/packages/web-runtime/tests/unit/container/bootstrap.spec.ts index 17714290665..c049e13c512 100644 --- a/packages/web-runtime/tests/unit/container/bootstrap.spec.ts +++ b/packages/web-runtime/tests/unit/container/bootstrap.spec.ts @@ -1,7 +1,6 @@ import { mock, mockDeep } from 'jest-mock-extended' import { createApp, defineComponent, App } from 'vue' -import { createStore } from 'vuex' -import { ConfigurationManager } from '@ownclouders/web-pkg' +import { ConfigurationManager, useAppsStore } from '@ownclouders/web-pkg' import { initializeApplications, announceApplicationsReady, @@ -10,7 +9,7 @@ import { announceConfiguration } from '../../../src/container/bootstrap' import { buildApplication } from '../../../src/container/application' -import { defaultStoreMockOptions } from 'web-test-helpers/src' +import { createTestingPinia } from 'web-test-helpers/src' jest.mock('../../../src/container/application') @@ -51,9 +50,10 @@ describe('initialize applications', () => { expect(errorSpy.mock.calls[0][0]).toMatchObject(fishyError) expect(errorSpy.mock.calls[1][0]).toMatchObject(fishyError) + createTestingPinia() await announceApplicationsReady({ app: mock(), - store: createStore(defaultStoreMockOptions), + appsStore: useAppsStore(), applications }) expect(ready).toHaveBeenCalledTimes(2) diff --git a/packages/web-test-helpers/src/mocks/pinia.ts b/packages/web-test-helpers/src/mocks/pinia.ts index b22f22ac854..d416b959f22 100644 --- a/packages/web-test-helpers/src/mocks/pinia.ts +++ b/packages/web-test-helpers/src/mocks/pinia.ts @@ -6,11 +6,13 @@ import { Capabilities } from '../../../web-client/src/ocs' import { mock } from 'jest-mock-extended' import { SpaceResource } from '../../../web-client/src' import { Share } from '../../../web-client/src/helpers' +import { ApplicationNewFileHandler } from '../../../web-pkg/types' export { createTestingPinia } export type PiniaMockOptions = { stubActions?: boolean + appsState?: { newFileHandlers?: ApplicationNewFileHandler[] } authState?: { accessToken?: string idpContextReady?: boolean @@ -30,6 +32,7 @@ export type PiniaMockOptions = { export function createMockStore({ stubActions = true, + appsState = {}, authState = {}, themeState = {}, messagesState = {}, @@ -52,6 +55,7 @@ export function createMockStore({ return createTestingPinia({ stubActions, initialState: { + apps: { ...appsState }, auth: { ...authState }, messages: { messages: [], ...messagesState }, modals: { diff --git a/packages/web-test-helpers/src/mocks/store/defaultStoreMockOptions.ts b/packages/web-test-helpers/src/mocks/store/defaultStoreMockOptions.ts index 190106577ef..ba77d3f9607 100644 --- a/packages/web-test-helpers/src/mocks/store/defaultStoreMockOptions.ts +++ b/packages/web-test-helpers/src/mocks/store/defaultStoreMockOptions.ts @@ -19,12 +19,6 @@ export const defaultStoreMockOptions = { modules: { ...filesModuleMockOptions, ...runtimeModuleMockOptions, - apps: { - state: { - fileEditors: [], - meta: {} - } - }, External: { getters: { mimeTypes: jest.fn(() => ({})) diff --git a/web.d.ts b/web.d.ts index f692990daca..4a36ab89359 100644 --- a/web.d.ts +++ b/web.d.ts @@ -3,7 +3,6 @@ import { OwnCloudSdk } from '@ownclouders/web-client/src/types' import { UppyService } from '@ownclouders/web-pkg' import { Route, Router } from 'vue-router' -import { Store } from 'vuex' // This file must have at least one export or import on top-level export {} @@ -17,13 +16,11 @@ declare module 'vue' { $router: Router $route: Route - - $store: Store } interface GlobalComponents { // https://github.com/LinusBorg/portal-vue/issues/380 - Portal: (typeof import('portal-vue'))['Portal'] - PortalTarget: (typeof import('portal-vue'))['PortalTarget'] + Portal: typeof import('portal-vue')['Portal'] + PortalTarget: typeof import('portal-vue')['PortalTarget'] } }