diff --git a/packages/web-app-files/src/extensions.ts b/packages/web-app-files/src/extensions.ts new file mode 100644 index 00000000000..8d23073461a --- /dev/null +++ b/packages/web-app-files/src/extensions.ts @@ -0,0 +1,28 @@ +import { + ApplicationSetupOptions, + Extension, + useStore, + useRouter, + useClientService, + useConfigurationManager +} from '@ownclouders/web-pkg' +import { computed } from 'vue' +import { SDKSearch } from './search' + +export const extensions = ({ applicationConfig }: ApplicationSetupOptions) => { + const store = useStore() + const router = useRouter() + const clientService = useClientService() + const configurationManager = useConfigurationManager() + + return computed( + () => + [ + { + id: 'com.github.owncloud.web.files.search', + type: 'search', + searchProvider: new SDKSearch(store, router, clientService, configurationManager) + } + ] satisfies Extension[] + ) +} diff --git a/packages/web-app-files/src/index.ts b/packages/web-app-files/src/index.ts index c31c107cb27..8498c593a67 100644 --- a/packages/web-app-files/src/index.ts +++ b/packages/web-app-files/src/index.ts @@ -8,14 +8,12 @@ import SpaceDriveResolver from './views/spaces/DriveResolver.vue' import SpaceProjects from './views/spaces/Projects.vue' import TrashOverview from './views/trash/Overview.vue' import translations from '../l10n/translations.json' -import { quickActions } from '@ownclouders/web-pkg' +import { defineWebApplication, quickActions } from '@ownclouders/web-pkg' import store from './store' -import { SDKSearch } from './search' -import { eventBus } from '@ownclouders/web-pkg' -import { Registry } from './services' +import { extensions } from './extensions' import fileSideBars from './fileSideBars' import { buildRoutes } from '@ownclouders/web-pkg' -import { AppNavigationItem, AppReadyHookArgs } from '@ownclouders/web-pkg' +import { AppNavigationItem } from '@ownclouders/web-pkg' // dirty: importing view from other extension within project import SearchResults from '../../web-app-search/src/views/List.vue' @@ -24,7 +22,6 @@ import { isPersonalSpaceResource, isShareSpaceResource } from '@ownclouders/web-client/src/helpers' -import { configurationManager } from '@ownclouders/web-pkg' // just a dummy function to trick gettext tools function $gettext(msg) { @@ -40,7 +37,7 @@ const appInfo = { extensions: [], fileSideBars } -const navItems = (context): AppNavigationItem[] => { +export const navItems = (context): AppNavigationItem[] => { return [ { name(capabilities) { @@ -121,36 +118,33 @@ const navItems = (context): AppNavigationItem[] => { ] } -export default { - appInfo, - store, - routes: buildRoutes({ - App, - Favorites, - FilesDrop, - SearchResults, - Shares: { - SharedViaLink, - SharedWithMe, - SharedWithOthers - }, - Spaces: { - DriveResolver: SpaceDriveResolver, - Projects: SpaceProjects - }, - Trash: { - Overview: TrashOverview +export default defineWebApplication({ + setup(args) { + return { + appInfo, + store, + routes: buildRoutes({ + App, + Favorites, + FilesDrop, + SearchResults, + Shares: { + SharedViaLink, + SharedWithMe, + SharedWithOthers + }, + Spaces: { + DriveResolver: SpaceDriveResolver, + Projects: SpaceProjects + }, + Trash: { + Overview: TrashOverview + } + }), + navItems, + quickActions, + translations, + extensions: extensions(args) } - }), - navItems, - quickActions, - translations, - ready({ router, store, globalProperties }: AppReadyHookArgs) { - const { $clientService } = globalProperties - Registry.sdkSearch = new SDKSearch(store, router, $clientService, configurationManager) - - // when discussing the boot process of applications we need to implement a - // registry that does not rely on call order, aka first register "on" and only after emit. - eventBus.publish('app.search.register.provider', Registry.sdkSearch) } -} +}) diff --git a/packages/web-app-files/tests/unit/index.spec.ts b/packages/web-app-files/tests/unit/index.spec.ts index 24a96dc727a..8a3d1fd92f9 100644 --- a/packages/web-app-files/tests/unit/index.spec.ts +++ b/packages/web-app-files/tests/unit/index.spec.ts @@ -1,27 +1,27 @@ -import WebAppFiles from '../../src/index' +import { navItems } from '../../src/index' describe('Web app files', () => { describe('navItems', () => { describe('Personal', () => { it('should be enabled if user has a personal space', () => { - const navItems = WebAppFiles.navItems({ + const items = navItems({ $store: { getters: { 'runtime/spaces/spaces': [{ id: '1', driveType: 'personal', isOwner: () => true }] } } }) - expect(navItems[0].enabled({ spaces: { enabled: true } })).toBeTruthy() + expect(items[0].enabled({ spaces: { enabled: true } })).toBeTruthy() }) it('should be disabled if user has no a personal space', () => { - const navItems = WebAppFiles.navItems({ + const items = navItems({ $store: { getters: { 'runtime/spaces/spaces': [{ id: '1', driveType: 'project', isOwner: () => false }] } } }) - expect(navItems[0].enabled({ spaces: { enabled: true } })).toBeFalsy() + expect(items[0].enabled({ spaces: { enabled: true } })).toBeFalsy() }) }) }) diff --git a/packages/web-app-search/src/composables/index.ts b/packages/web-app-search/src/composables/index.ts new file mode 100644 index 00000000000..2cf7f77ad8c --- /dev/null +++ b/packages/web-app-search/src/composables/index.ts @@ -0,0 +1 @@ +export * from './useAvailableProviders' diff --git a/packages/web-app-search/src/composables/useAvailableProviders.ts b/packages/web-app-search/src/composables/useAvailableProviders.ts new file mode 100644 index 00000000000..5dae8663d83 --- /dev/null +++ b/packages/web-app-search/src/composables/useAvailableProviders.ts @@ -0,0 +1,14 @@ +import { SearchExtension, SearchProvider, useExtensionRegistry } from '@ownclouders/web-pkg' +import { computed, Ref } from 'vue' + +export const useAvailableProviders = (): Ref => { + const extensionRegistry = useExtensionRegistry() + + const availableProviders = computed(() => { + return extensionRegistry + .requestExtensions('search') + .map(({ searchProvider }) => searchProvider) + }) + + return availableProviders +} diff --git a/packages/web-app-search/src/index.ts b/packages/web-app-search/src/index.ts index 06e4c9bd3b8..8a62befaa3e 100644 --- a/packages/web-app-search/src/index.ts +++ b/packages/web-app-search/src/index.ts @@ -1,8 +1,6 @@ import SearchBar from './portals/SearchBar.vue' import App from './App.vue' import List from './views/List.vue' -import { providerStore } from './service' -import { eventBus, SearchProvider } from '@ownclouders/web-pkg' import { Component } from 'vue' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -13,10 +11,6 @@ const $gettext = (msg) => { return msg } -eventBus.subscribe('app.search.register.provider', (provider: SearchProvider) => { - providerStore.addProvider(provider) -}) - export default { appInfo: { name: $gettext('Search'), diff --git a/packages/web-app-search/src/portals/SearchBar.vue b/packages/web-app-search/src/portals/SearchBar.vue index e172df33bed..9ff92a63bfe 100644 --- a/packages/web-app-search/src/portals/SearchBar.vue +++ b/packages/web-app-search/src/portals/SearchBar.vue @@ -103,7 +103,6 @@ diff --git a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts index f5d71aca564..e84b51b366a 100644 --- a/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts +++ b/packages/web-app-search/tests/unit/portals/SearchBar.spec.ts @@ -1,6 +1,7 @@ import SearchBar from '../../../src/portals/SearchBar.vue' import flushPromises from 'flush-promises' import { mock } from 'jest-mock-extended' +import { ref } from 'vue' import { defineComponent } from 'vue' import { createStore, @@ -10,6 +11,7 @@ import { defaultComponentMocks, RouteLocation } from 'web-test-helpers' +import { useAvailableProviders } from '../../../src/composables' const component = defineComponent({ emits: ['click', 'keyup'], @@ -57,13 +59,8 @@ const selectors = { } jest.mock('lodash-es/debounce', () => (fn) => fn) -jest.mock('web-app-search/src/service/providerStore', () => ({ - get providerStore() { - return { - availableProviders: [providerFiles, providerContacts] - } - } -})) +jest.mock('../../../src/composables/useAvailableProviders') + beforeEach(() => { providerFiles.previewSearch.search.mockImplementation(() => { return { @@ -220,6 +217,8 @@ describe('Search Bar portal component', () => { }) function getMountedWrapper({ data = {}, mocks = {}, isUserContextReady = true } = {}) { + jest.mocked(useAvailableProviders).mockReturnValue(ref([providerFiles, providerContacts])) + const currentRoute = mock({ name: 'files-spaces-generic', query: { diff --git a/packages/web-app-search/tests/unit/service/providerStore.spec.ts b/packages/web-app-search/tests/unit/service/providerStore.spec.ts deleted file mode 100644 index 73d9ff89b4b..00000000000 --- a/packages/web-app-search/tests/unit/service/providerStore.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { providerStore } from '../../../src/service' - -const dummyProviderOne = { - id: 'id', - available: true, - reset: jest.fn(), - updateTerm: jest.fn(), - activate: jest.fn() -} - -const dummyProviderTwo = { ...dummyProviderOne, available: false } - -beforeEach(() => (providerStore.providers = [])) - -describe('providerStore service', () => { - test('new providers can be added to the store', () => { - providerStore.addProvider(dummyProviderOne) - providerStore.addProvider(dummyProviderTwo) - expect(providerStore.providers.length).toBe(2) - }) - test('only available providers can be requested', () => { - providerStore.addProvider(dummyProviderOne) - providerStore.addProvider(dummyProviderTwo) - expect(providerStore.availableProviders.length).toBe(1) - }) -}) diff --git a/packages/web-app-search/tests/unit/views/List.spec.ts b/packages/web-app-search/tests/unit/views/List.spec.ts index f74e05abaad..aad5b3bfc3a 100644 --- a/packages/web-app-search/tests/unit/views/List.spec.ts +++ b/packages/web-app-search/tests/unit/views/List.spec.ts @@ -1,26 +1,29 @@ import List from '../../../src/views/List.vue' -import { providerStore } from '../../../src/service' import { defaultComponentMocks, mount } from 'web-test-helpers' +import { useAvailableProviders } from '../../../src/composables' +import { ref } from 'vue' +import { SearchProvider, queryItemAsString } from '@ownclouders/web-pkg' +import { mock } from 'jest-mock-extended' -const mockProvider = { +const mockProvider = mock({ id: 'p1', available: true, - reset: jest.fn(), - updateTerm: jest.fn(), - activate: jest.fn(), listSearch: { search: jest.fn() - } as any -} - -beforeEach(() => { - providerStore.providers = [mockProvider] + } }) +jest.mock('../../../src/composables/useAvailableProviders') +jest.mock('@ownclouders/web-pkg', () => ({ + ...jest.requireActual('@ownclouders/web-pkg'), + useRouteQuery: jest.fn(), + queryItemAsString: jest.fn() +})) + describe('search result List view', () => { it('requests the listSearch from the current active provider', () => { const { wrapper } = getWrapper() - expect(wrapper.vm.$data.listSearch).toMatchObject(mockProvider.listSearch) + expect(wrapper.vm.listSearch).toMatchObject(mockProvider.listSearch) }) it('triggers the search', async () => { const { wrapper } = getWrapper() @@ -30,8 +33,9 @@ describe('search result List view', () => { }) const getWrapper = () => { + jest.mocked(useAvailableProviders).mockReturnValue(ref([mockProvider])) + jest.mocked(queryItemAsString).mockReturnValue('p1') const mocks = { ...defaultComponentMocks() } - mocks.$route.query = { provider: 'p1' } return { wrapper: mount(List, { global: { mocks } diff --git a/packages/web-app-skeleton/src/extensions.ts b/packages/web-app-skeleton/src/extensions.ts new file mode 100644 index 00000000000..9266d5179ef --- /dev/null +++ b/packages/web-app-skeleton/src/extensions.ts @@ -0,0 +1,16 @@ +import { ApplicationSetupOptions, Extension } from '@ownclouders/web-pkg' +import { computed } from 'vue' +import { GitHubSearch } from './search/github' + +export const extensions = ({ applicationConfig }: ApplicationSetupOptions) => { + return computed( + () => + [ + { + id: 'com.github.owncloud.web.skeleton.search.github', + type: 'search', + searchProvider: new GitHubSearch() + } + ] satisfies Extension[] + ) +} diff --git a/packages/web-app-skeleton/src/index.ts b/packages/web-app-skeleton/src/index.ts index 626f5d0c593..a1bb52f6ec2 100644 --- a/packages/web-app-skeleton/src/index.ts +++ b/packages/web-app-skeleton/src/index.ts @@ -1,6 +1,6 @@ +import { defineWebApplication } from '@ownclouders/web-pkg' import App from './App.vue' -import { GitHubSearch } from './search/github' -import { eventBus } from '@ownclouders/web-pkg' +import { extensions } from './extensions' const appInfo = { name: 'web-app-skeleton', @@ -10,10 +10,6 @@ const appInfo = { extensions: [] } -const injectSearch = (): void => { - eventBus.publish('app.search.register.provider', new GitHubSearch()) -} - const injectExtensions = async (api): Promise => { // the promise is just there to showcase lazy loading of extensions await new Promise((resolve) => setTimeout(resolve, 2000)) @@ -29,26 +25,30 @@ const injectExtensions = async (api): Promise => { }) } -export default { - appInfo, - navItems: [ - { - name: 'skeleton', - icon: appInfo.icon, - route: { - path: `/${appInfo.id}/` +export default defineWebApplication({ + setup: (args) => { + return { + appInfo, + navItems: [ + { + name: 'skeleton', + icon: appInfo.icon, + route: { + path: `/${appInfo.id}/` + } + } + ], + routes: [ + { + name: 'skeleton', + path: '/', + component: App + } + ], + extensions: extensions(args), + async mounted(api) { + await injectExtensions(api) } } - ], - routes: [ - { - name: 'skeleton', - path: '/', - component: App - } - ], - async mounted(api) { - await injectSearch() - await injectExtensions(api) } -} +}) diff --git a/packages/web-pkg/src/apps/types.ts b/packages/web-pkg/src/apps/types.ts index e6d3b35c3bb..3cfcd492817 100644 --- a/packages/web-pkg/src/apps/types.ts +++ b/packages/web-pkg/src/apps/types.ts @@ -1,6 +1,6 @@ import { App, ComponentCustomProperties, Ref } from 'vue' import { RouteLocationRaw, Router, RouteRecordRaw } from 'vue-router' -import { Store } from 'vuex' +import { Module, Store } from 'vuex' import { Extension } from '../composables/piniaStores' export interface AppReadyHookArgs { @@ -31,10 +31,11 @@ export interface AppNavigationItem { */ export interface ApplicationQuickAction { id?: string - label?: string + label?: (...args) => string | string icon?: string - handler?: () => Promise - displayed?: boolean + iconFillType?: string + handler?: (...args) => Promise | void + displayed?: (...args) => boolean | boolean } /** @@ -64,22 +65,22 @@ export interface ApplicationInformation { */ export interface ApplicationTranslations { [lang: string]: { - [key: string]: string + [key: string]: string | string[] } } /** ClassicApplicationScript reflects classic application script structure */ export interface ClassicApplicationScript { appInfo?: ApplicationInformation - store?: Store + store?: Module routes?: ((...args) => RouteRecordRaw[]) | RouteRecordRaw[] navItems?: ((...args) => AppNavigationItem[]) | AppNavigationItem[] quickActions?: ApplicationQuickActions translations?: ApplicationTranslations extensions?: Ref initialize?: () => void - ready?: () => void - mounted?: () => void + ready?: (args: AppReadyHookArgs) => void + mounted?: (...args) => void // TODO: move this to its own type setup?: (args: { applicationConfig: AppConfigObject }) => ClassicApplicationScript } diff --git a/packages/web-pkg/src/composables/piniaStores/extensionRegistry.ts b/packages/web-pkg/src/composables/piniaStores/extensionRegistry.ts index 9ee2cbe6235..6574c122763 100644 --- a/packages/web-pkg/src/composables/piniaStores/extensionRegistry.ts +++ b/packages/web-pkg/src/composables/piniaStores/extensionRegistry.ts @@ -1,4 +1,5 @@ import { Action } from '../actions' +import { SearchProvider } from '../../components/Search' import { defineStore } from 'pinia' import { Ref, unref } from 'vue' @@ -12,7 +13,12 @@ export interface ActionExtension extends BaseExtension { action: Action } -export type Extension = ActionExtension // | FooExtension | BarExtension +export interface SearchExtension extends BaseExtension { + type: 'search' + searchProvider: SearchProvider +} + +export type Extension = ActionExtension | SearchExtension export const useExtensionRegistry = defineStore('extensionRegistry', { state: () => ({ extensions: [] as Ref[] }), @@ -25,7 +31,9 @@ export const useExtensionRegistry = defineStore('extensionRegistry', { requestExtensions: (state) => (type: string) => { - return state.extensions.map((e) => unref(e).filter((e) => e.type === type)).flat() + return state.extensions + .map((e) => unref(e).filter((e) => e.type === type)) + .flat() as ExtensionType[] } } }) diff --git a/packages/web-pkg/src/helpers/resource/filter.ts b/packages/web-pkg/src/helpers/resource/filter.ts index a986c145b22..b46d0b2c378 100644 --- a/packages/web-pkg/src/helpers/resource/filter.ts +++ b/packages/web-pkg/src/helpers/resource/filter.ts @@ -1,7 +1,12 @@ import Fuse from 'fuse.js' import { defaultFuseOptions } from '../fuse' +import { Resource } from '@ownclouders/web-client' -export const filterResources = (resources: unknown[], term: string, limit?: number): unknown[] => { +export const filterResources = ( + resources: T[], + term: string, + limit?: number +): T[] => { const engine = new Fuse(resources, { ...defaultFuseOptions, keys: ['name', 'type', 'icon', 'extension', 'tags'] diff --git a/packages/web-pkg/src/quickActions.ts b/packages/web-pkg/src/quickActions.ts index dd646a6c71c..0f380f08f79 100644 --- a/packages/web-pkg/src/quickActions.ts +++ b/packages/web-pkg/src/quickActions.ts @@ -6,6 +6,7 @@ import { Language } from 'vue3-gettext' import { ClientService, PasswordPolicyService } from './services' import { Ability } from '@ownclouders/web-client/src/helpers/resource/types' import { Store } from 'vuex' +import { ApplicationQuickActions } from './apps' export function canShare(item, store) { const { capabilities } = store.state.user @@ -103,4 +104,4 @@ export default { }, displayed: canShare } -} // FIXME: fix type, then add: satisfies ApplicationQuickActions +} satisfies ApplicationQuickActions diff --git a/packages/web-pkg/src/router/deprecated.ts b/packages/web-pkg/src/router/deprecated.ts index 259049c5caf..536a2da5754 100644 --- a/packages/web-pkg/src/router/deprecated.ts +++ b/packages/web-pkg/src/router/deprecated.ts @@ -46,7 +46,7 @@ const deprecatedRedirect = (routeConfig: { * listed routes only exist to keep backwards compatibility intact, * all routes written in a flat syntax to keep them readable. */ -export const buildRoutes = (): RouteLocationNamedRaw[] => +export const buildRoutes = (): RouteRecordRaw[] => [ { path: '/list', diff --git a/packages/web-pkg/src/router/index.ts b/packages/web-pkg/src/router/index.ts index cbc695c9144..1f1ca364dd1 100644 --- a/packages/web-pkg/src/router/index.ts +++ b/packages/web-pkg/src/router/index.ts @@ -1,4 +1,4 @@ -import { RouteLocationNamedRaw } from 'vue-router' +import { RouteRecordRaw } from 'vue-router' import { buildRoutes as buildCommonRoutes, @@ -41,7 +41,7 @@ const ROOT_ROUTE = { redirect: (to) => createLocationSpaces('files-spaces-generic', to) } -const buildRoutes = (components: RouteComponents): RouteLocationNamedRaw[] => [ +const buildRoutes = (components: RouteComponents): RouteRecordRaw[] => [ ROOT_ROUTE, ...buildCommonRoutes(components), ...buildSharesRoutes(components), diff --git a/packages/web-pkg/tests/unit/helpers/resource/filter.spec.ts b/packages/web-pkg/tests/unit/helpers/resource/filter.spec.ts index 4e0dc375aad..bdc8ff6986a 100644 --- a/packages/web-pkg/tests/unit/helpers/resource/filter.spec.ts +++ b/packages/web-pkg/tests/unit/helpers/resource/filter.spec.ts @@ -1,15 +1,23 @@ +import { Resource } from '@ownclouders/web-client' import { filterResources } from '../../../../src/helpers/resource' describe('filterResources', () => { it('filters given resources by given term', () => { - const resultset = filterResources([{ name: 'foo' }, { name: 'bar' }], 'foo') + const resultset = filterResources( + [{ name: 'foo' } as Resource, { name: 'bar' } as Resource], + 'foo' + ) expect(resultset).toMatchObject([{ name: 'foo' }]) expect(resultset.length).toBe(1) }) it('can limit the resultset', () => { - const filter = filterResources([{ name: 'foo' }, { name: 'foo' }], 'foo', 1) + const filter = filterResources( + [{ name: 'foo' } as Resource, { name: 'foo' } as Resource], + 'foo', + 1 + ) expect(filter).toMatchObject([{ name: 'foo' }]) expect(filter.length).toBe(1) diff --git a/packages/web-runtime/src/container/api.ts b/packages/web-runtime/src/container/api.ts index 8c4a5767cc4..23743d01bf4 100644 --- a/packages/web-runtime/src/container/api.ts +++ b/packages/web-runtime/src/container/api.ts @@ -3,7 +3,7 @@ import clone from 'lodash-es/clone' import { RuntimeApi } from './types' import { ApiError } from '@ownclouders/web-pkg' import { get, isEqual, isObject, isArray, merge } from 'lodash-es' -import { Store } from 'vuex' +import { Module, Store } from 'vuex' import { App, Component, h } from 'vue' import { ApplicationQuickActions, @@ -148,9 +148,9 @@ const announceQuickActions = ( const announceStore = ( applicationName: string, store: Store, - applicationStore: unknown + applicationStore: Module ): void => { - const obtainedStore: Store = get(applicationStore, 'default', applicationStore) + const obtainedStore: Module = get(applicationStore, 'default', applicationStore) if (!isObject(obtainedStore)) { throw new ApiError("store can't be blank") @@ -260,7 +260,7 @@ export const buildRuntimeApi = ({ announceTranslations(supportedLanguages, gettext, appTranslations), announceQuickActions: (quickActions: ApplicationQuickActions): void => announceQuickActions(store, quickActions), - announceStore: (applicationStore: Store): void => + announceStore: (applicationStore: Module): void => announceStore(applicationName, store, applicationStore), announceExtension: (extension: { [key: string]: unknown }): void => announceExtension(applicationId, store, extension), diff --git a/packages/web-runtime/src/container/types.ts b/packages/web-runtime/src/container/types.ts index 55e63ce2a2e..8137cdd098e 100644 --- a/packages/web-runtime/src/container/types.ts +++ b/packages/web-runtime/src/container/types.ts @@ -1,4 +1,4 @@ -import { Store } from 'vuex' +import { Module, Store } from 'vuex' import { Router, RouteRecordRaw } from 'vue-router' import { App, Component } from 'vue' import { @@ -16,7 +16,7 @@ export interface RuntimeApi { announceNavigationItems: (navigationItems: AppNavigationItem[]) => void announceTranslations: (appTranslations: ApplicationTranslations) => void announceQuickActions: (quickActions: ApplicationQuickActions) => void - announceStore: (applicationStore: Store) => void + announceStore: (applicationStore: Module) => void announceExtension: (extension: { [key: string]: unknown }) => void requestStore: () => Store requestRouter: () => Router