From 26ef27bfb1a348d14e94dbc02f5c31f7afcdcbb7 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Tue, 7 Apr 2026 03:13:00 +0200 Subject: [PATCH] refactor(appstore): migrate app bundles view to Vue 3 Signed-off-by: Ferdinand Thiessen --- apps/appstore/src/AppstoreApp.vue | 10 +- apps/appstore/src/apps.d.ts | 6 + apps/appstore/src/composables/useActions.ts | 44 ++++--- apps/appstore/src/router/routes.ts | 6 + apps/appstore/src/service/api.ts | 31 ++++- apps/appstore/src/store/apps.ts | 44 ++++++- apps/appstore/src/utils/appStatus.ts | 24 +++- apps/appstore/src/views/AppstoreBundles.vue | 112 ++++++++++++++++++ .../appstore/src/views/AppstoreNavigation.vue | 30 ++++- package-lock.json | 15 +-- package.json | 1 + 11 files changed, 291 insertions(+), 32 deletions(-) create mode 100644 apps/appstore/src/views/AppstoreBundles.vue diff --git a/apps/appstore/src/AppstoreApp.vue b/apps/appstore/src/AppstoreApp.vue index 35c9bb86f81..e97c1f6e9f9 100644 --- a/apps/appstore/src/AppstoreApp.vue +++ b/apps/appstore/src/AppstoreApp.vue @@ -15,7 +15,15 @@ import { APPSTORE_CATEGORY_NAMES } from './constants.ts' const route = useRoute() -const currentCategory = computed(() => [route.params.category].flat()[0] ?? 'discover') +const currentCategory = computed(() => { + if (route.params.category) { + return [route.params.category].flat()[0]! + } + if (route.name === 'apps-bundles') { + return 'bundles' + } + return 'discover' +}) const heading = computed(() => APPSTORE_CATEGORY_NAMES[currentCategory.value] ?? currentCategory.value) const pageTitle = computed(() => `${heading.value} - ${t('appstore', 'App store')}`) diff --git a/apps/appstore/src/apps.d.ts b/apps/appstore/src/apps.d.ts index a82aac194e2..348de979279 100644 --- a/apps/appstore/src/apps.d.ts +++ b/apps/appstore/src/apps.d.ts @@ -138,3 +138,9 @@ export interface IAppstoreExApp extends IAppstoreApp { error?: string releases: IAppstoreExAppRelease[] } + +export interface IAppBundle { + id: string + name: string + appIdentifiers: readonly string[] +} diff --git a/apps/appstore/src/composables/useActions.ts b/apps/appstore/src/composables/useActions.ts index e7f40ba6998..8e1b541542a 100644 --- a/apps/appstore/src/composables/useActions.ts +++ b/apps/appstore/src/composables/useActions.ts @@ -6,29 +6,40 @@ import type { MaybeRefOrGetter } from 'vue' import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' -import { mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js' +import { mdiAlertCircleCheckOutline, mdiCheck, mdiClose, mdiDownload, mdiTrashCanOutline, mdiUpdate } from '@mdi/js' import { t } from '@nextcloud/l10n' import { computed, toValue } from 'vue' import { useAppsStore } from '../store/apps.ts' import { useUpdatesStore } from '../store/updates.ts' -import { canDisable, canEnable, canForceEnable, canInstall, canUninstall, canUpdate } from '../utils/appStatus.ts' +import { canDisable, canEnable, canInstall, canUninstall, canUpdate, needForceEnable } from '../utils/appStatus.ts' + +type AppAction = { + id: string + icon: string + label: (app: IAppstoreApp | IAppstoreExApp) => string + callback: (app: IAppstoreApp | IAppstoreExApp) => Promise + variant?: 'primary' | 'error' | 'warning' + inline?: boolean +} const AppAction = Object.freeze({ INSTALL: { id: 'install', icon: mdiDownload, - variant: 'primary', label: (app: IAppstoreApp | IAppstoreExApp) => { if (app.app_api) { return t('appstore', 'Deploy and enable') } - return t('appstore', 'Download and enable') + if (app.needsDownload) { + return t('appstore', 'Download and enable') + } + return t('appstore', 'Install and enable') }, async callback(app: IAppstoreApp | IAppstoreExApp) { const store = useAppsStore() await store.enableApp(app.id) }, - } as const, + } as AppAction, ENABLE: { id: 'enable', icon: mdiCheck, @@ -38,37 +49,38 @@ const AppAction = Object.freeze({ const store = useAppsStore() await store.enableApp(app.id) }, - } as const, + } as AppAction, FORCE_ENABLE: { id: 'force-enable', - icon: mdiCheck, - variant: 'primary', + icon: mdiAlertCircleCheckOutline, + inline: false, label: () => t('appstore', 'Force enable'), + variant: 'warning', async callback(app: IAppstoreApp | IAppstoreExApp) { const store = useAppsStore() await store.forceEnableApp(app.id) }, - } as const, + } as AppAction, DISABLE: { id: 'disable', icon: mdiClose, - variant: 'tertiary', label: () => t('appstore', 'Disable'), async callback(app: IAppstoreApp | IAppstoreExApp) { const store = useAppsStore() await store.disableApp(app.id) }, - } as const, + } as AppAction, REMOVE: { id: 'remove', icon: mdiTrashCanOutline, variant: 'error', + inline: false, label: () => t('appstore', 'Remove'), async callback(app: IAppstoreApp | IAppstoreExApp) { const store = useAppsStore() await store.uninstallApp(app.id) }, - } as const, + } as AppAction, UPDATE: { id: 'update', icon: mdiUpdate, @@ -78,7 +90,7 @@ const AppAction = Object.freeze({ const store = useUpdatesStore() await store.updateApp(app.id) }, - } as const, + } as AppAction, }) /** @@ -97,12 +109,12 @@ export function useActions(app: MaybeRefOrGetter) actions.push(AppAction.DISABLE) } - if (canInstall(toValue(app))) { + if (needForceEnable(toValue(app))) { + actions.push(AppAction.FORCE_ENABLE) + } else if (canInstall(toValue(app))) { actions.push(AppAction.INSTALL) } else if (canEnable(toValue(app))) { actions.push(AppAction.ENABLE) - } else if (canForceEnable(toValue(app))) { - actions.push(AppAction.FORCE_ENABLE) } if (canUninstall(toValue(app))) { diff --git a/apps/appstore/src/router/routes.ts b/apps/appstore/src/router/routes.ts index 1f77f49f060..b1e79e1e842 100644 --- a/apps/appstore/src/router/routes.ts +++ b/apps/appstore/src/router/routes.ts @@ -13,6 +13,7 @@ const appstoreEnabled = loadState('appstore', 'appstoreEnabled', true) // Dynamic loading const AppstoreDiscover = defineAsyncComponent(() => import('../views/AppstoreDiscover.vue')) const AppstoreManage = defineAsyncComponent(() => import('../views/AppstoreManage.vue')) +const AppstoreBundles = defineAsyncComponent(() => import('../views/AppstoreBundles.vue')) const routes: RouteRecordRaw[] = [ { @@ -32,6 +33,11 @@ const routes: RouteRecordRaw[] = [ name: 'apps-discover', component: AppstoreDiscover, }, + { + path: 'bundles/:id?', + name: 'apps-bundles', + component: AppstoreBundles, + }, { path: ':category(installed|enabled|disabled|updates)/:id?', name: 'apps-manage', diff --git a/apps/appstore/src/service/api.ts b/apps/appstore/src/service/api.ts index 3c029987263..94248dbc229 100644 --- a/apps/appstore/src/service/api.ts +++ b/apps/appstore/src/service/api.ts @@ -9,6 +9,7 @@ import type { IAppstoreApp, IAppstoreCategory } from '../apps.d.ts' import axios from '@nextcloud/axios' import { addPasswordConfirmationInterceptors, PwdConfirmationMode } from '@nextcloud/password-confirmation' import { generateOcsUrl } from '@nextcloud/router' +import PQueue from 'p-queue' import { APPSTORE_CATEGORY_ICONS } from '../constants.ts' addPasswordConfirmationInterceptors(axios) @@ -21,8 +22,11 @@ const Url = Object.freeze({ disable: `${BASE_URL}/apps/disable`, uninstall: `${BASE_URL}/apps/uninstall`, update: `${BASE_URL}/apps/update`, + bundleEnable: `${BASE_URL}/bundles/enable`, }) +const queue = new PQueue({ concurrency: 1 }) + /** * Enable an app by its app id * @@ -30,7 +34,9 @@ const Url = Object.freeze({ * @param force - Whether to force enable the app */ export async function enableApp(appId: string, force = false) { - await axios.post(Url.enable, { appId, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict }) + return queue.add(async () => { + await axios.post(Url.enable, { appId, force: force || undefined }, { confirmPassword: PwdConfirmationMode.Strict }) + }) } /** @@ -39,7 +45,9 @@ export async function enableApp(appId: string, force = false) { * @param appId - The app to disable */ export async function disableApp(appId: string) { - await axios.post(Url.disable, { appId }, { confirmPassword: PwdConfirmationMode.Lax }) + return queue.add(async () => { + await axios.post(Url.disable, { appId }, { confirmPassword: PwdConfirmationMode.Lax }) + }) } /** @@ -48,7 +56,9 @@ export async function disableApp(appId: string) { * @param appId - The app id to update */ export async function updateApp(appId: string) { - await axios.post(Url.update, { appId }, { confirmPassword: PwdConfirmationMode.Strict }) + return queue.add(async () => { + await axios.post(Url.update, { appId }, { confirmPassword: PwdConfirmationMode.Strict }) + }) } /** @@ -57,7 +67,9 @@ export async function updateApp(appId: string) { * @param appId - The app to uninstall */ export async function uninstallApp(appId: string) { - await axios.post(Url.uninstall, { appId }, { confirmPassword: PwdConfirmationMode.Lax }) + return queue.add(async () => { + await axios.post(Url.uninstall, { appId }, { confirmPassword: PwdConfirmationMode.Strict }) + }) } /** @@ -78,3 +90,14 @@ export async function getCategories() { } return data.ocs.data } + +/** + * Enable an app bundle by its id + * + * @param bundleId - The id of the bundle to enable + */ +export async function enableBundle(bundleId: string) { + return queue.add(async () => { + await axios.post(Url.bundleEnable, { bundleId }, { confirmPassword: PwdConfirmationMode.Strict }) + }) +} diff --git a/apps/appstore/src/store/apps.ts b/apps/appstore/src/store/apps.ts index 89516489fe7..b86b83e4f28 100644 --- a/apps/appstore/src/store/apps.ts +++ b/apps/appstore/src/store/apps.ts @@ -3,12 +3,13 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { IAppstoreApp, IAppstoreCategory, IAppstoreExApp } from '../apps.d.ts' +import type { IAppBundle, IAppstoreApp, IAppstoreCategory, IAppstoreExApp } from '../apps.d.ts' import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { defineStore } from 'pinia' -import { computed, ref } from 'vue' +import { computed, readonly, ref } from 'vue' import * as api from '../service/api.ts' import { rebuildNavigation } from '../service/rebuild-navigation.ts' import { canDisable, canInstall, canUninstall, needForceEnable } from '../utils/appStatus.ts' @@ -26,6 +27,11 @@ export const useAppsStore = defineStore('apps', () => { * All app categories available in the appstore */ const categories = ref([]) + /** + * All app bundles available in the appstore + */ + const bundles = readonly(loadState('appstore', 'appstoreBundles')) + /** * Loading state of the store */ @@ -175,6 +181,38 @@ export const useAppsStore = defineStore('apps', () => { } } + /** + * Enable a whole bundle of apps by its id + * + * @param bundleId - The id of the bundle to enable + */ + async function enableBundle(bundleId: string) { + const bundle = bundles.find((b) => b.id === bundleId) + if (!bundle) { + throw new Error(`Bundle with id ${bundleId} not found`) + } + + try { + for (const appId of bundle.appIdentifiers) { + const app = getAppById(appId)! + app.loading = true + } + await api.enableBundle(bundle.id) + for (const appId of bundle.appIdentifiers) { + const app = getAppById(appId)! + app.active = true + app.installed = true + app.removable = true + await rebuildNavigation() + } + } finally { + for (const appId of bundle.appIdentifiers) { + const app = getAppById(appId)! + app.loading = false + } + } + } + /** * Load the app categories from the backend */ @@ -211,6 +249,7 @@ export const useAppsStore = defineStore('apps', () => { return { apps, + bundles, categories, isLoadingApps, isLoadingCategories, @@ -218,6 +257,7 @@ export const useAppsStore = defineStore('apps', () => { disableApp, enableApp, uninstallApp, + enableBundle, getAppById, getAppsByCategory, diff --git a/apps/appstore/src/utils/appStatus.ts b/apps/appstore/src/utils/appStatus.ts index 99e00362ad9..08516109df9 100644 --- a/apps/appstore/src/utils/appStatus.ts +++ b/apps/appstore/src/utils/appStatus.ts @@ -11,7 +11,20 @@ import type { IAppstoreApp, IAppstoreExApp } from '../apps.d.ts' * @param app - The app to check if installable */ export function canInstall(app: IAppstoreApp | IAppstoreExApp) { - return !app.installed && (!app.missingDependencies || app.missingDependencies.length === 0) + if (app.installed || app.internal) { + return false + } + + if (app.missingDependencies === undefined || app.missingDependencies.length === 0) { + return true + } + + if (!app.isCompatible && app.missingDependencies.length === 1) { + // incompatible so can be installed but has to be force-enabled + return true + } + + return false } /** @@ -41,6 +54,15 @@ export function canForceEnable(app: IAppstoreApp | IAppstoreExApp) { return !app.active && (app.installed || canInstall(app)) } +/** + * Check if an app needs to be force-enabled + * + * @param app - The app to check + */ +export function needForceEnable(app: IAppstoreApp | IAppstoreExApp) { + return !app.active && !app.isCompatible +} + /** * Check if an app can be disabled. * diff --git a/apps/appstore/src/views/AppstoreBundles.vue b/apps/appstore/src/views/AppstoreBundles.vue new file mode 100644 index 00000000000..452353a46ea --- /dev/null +++ b/apps/appstore/src/views/AppstoreBundles.vue @@ -0,0 +1,112 @@ + + + + + + + diff --git a/apps/appstore/src/views/AppstoreNavigation.vue b/apps/appstore/src/views/AppstoreNavigation.vue index f334b11742a..5dd8a96c8ec 100644 --- a/apps/appstore/src/views/AppstoreNavigation.vue +++ b/apps/appstore/src/views/AppstoreNavigation.vue @@ -6,9 +6,11 @@