refactor(appstore): migrate app bundles view to Vue 3

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-04-07 03:13:00 +02:00
parent a4d8b3be43
commit 26ef27bfb1
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
11 changed files with 291 additions and 32 deletions

View file

@ -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')}`)

View file

@ -138,3 +138,9 @@ export interface IAppstoreExApp extends IAppstoreApp {
error?: string
releases: IAppstoreExAppRelease[]
}
export interface IAppBundle {
id: string
name: string
appIdentifiers: readonly string[]
}

View file

@ -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<void>
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<IAppstoreApp | IAppstoreExApp>)
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))) {

View file

@ -13,6 +13,7 @@ const appstoreEnabled = loadState<boolean>('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',

View file

@ -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 })
})
}

View file

@ -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<IAppstoreCategory[]>([])
/**
* All app bundles available in the appstore
*/
const bundles = readonly(loadState<IAppBundle[]>('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,

View file

@ -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.
*

View file

@ -0,0 +1,112 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { IAppBundle, IAppstoreApp } from '../apps.d.ts'
import { mdiDownloadMultiple } from '@mdi/js'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppTable from '../components/AppTable/AppTable.vue'
import { useAppsStore } from '../store/apps.ts'
import { canEnable } from '../utils/appStatus.ts'
const store = useAppsStore()
const appBundles = computed(() => store.bundles.map((bundle) => ({
...bundle,
apps: bundle.appIdentifiers
.map((id) => store.apps.find((app) => app.id === id))
.filter(Boolean) as IAppstoreApp[],
isEnabling: false,
})))
/**
* Check if a bundle can be enabled
*
* @param bundle - The bundle to check
*/
function canEnableBundle(bundle: IAppBundle): boolean {
return bundle.appIdentifiers.every((id) => {
const app = store.apps.find((app) => app.id === id)
return app && (app.active || canEnable(app))
})
}
/**
* Check if a bundle is enabled
*
* @param bundle - The bundle to check
*/
function isBundleEnabled(bundle: IAppBundle): boolean {
return bundle.appIdentifiers.every((id) => {
const app = store.apps.find((app) => app.id === id)
return app && app.active
})
}
/**
* Enable all apps in a bundle
*
* @param bundle - The bundle to enable all apps
*/
async function enableAll(bundle: typeof appBundles.value[number]) {
bundle.isEnabling = true
await store.enableBundle(bundle.id)
bundle.isEnabling = false
}
</script>
<template>
<!-- Apps list -->
<NcEmptyContent
v-if="store.isLoadingApps"
:name="t('appstore', 'Loading app list')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<template v-else>
<section v-for="bundle of appBundles" :key="bundle.id">
<div :class="$style.appstoreBundles__header">
<h3>{{ bundle.name }}</h3>
<NcButton
v-if="!isBundleEnabled(bundle)"
:disabled="!canEnableBundle(bundle)"
variant="primary"
@click="enableAll(bundle)">
<template #icon>
<NcIconSvgWrapper :path="mdiDownloadMultiple" />
</template>
{{ t('appstore', 'Download and enable all') }}
</NcButton>
</div>
<AppTable
:class="$style.appstoreBundles__appTable"
:apps="bundle.apps" />
</section>
</template>
</template>
<style module>
.appstoreBundles__header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: var(--default-clickable-area);
padding-inline: var(--default-grid-baseline);
}
.appstoreBundles__appTable:last-of-type {
margin-bottom: var(--body-container-margin);
}
</style>

View file

@ -6,9 +6,11 @@
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
import NcAppNavigationSearch from '@nextcloud/vue/components/NcAppNavigationSearch'
import NcAppNavigationSpacer from '@nextcloud/vue/components/NcAppNavigationSpacer'
import NcCounterBubble from '@nextcloud/vue/components/NcCounterBubble'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
@ -24,6 +26,27 @@ const updateStore = useUpdatesStore()
const categories = computed(() => store.categories)
const categoriesLoading = computed(() => store.isLoadingCategories)
const router = useRouter()
const search = ref('')
watch(search, (newValue, oldValue) => {
if (newValue.trim() === oldValue.trim()) {
return
}
if (router.currentRoute.value.name === 'apps-search') {
router.replace({
name: 'apps-search',
query: { q: newValue },
})
return
}
router.push({
name: 'apps-search',
query: { q: newValue },
})
})
/**
* Check if the current instance has a support subscription from the Nextcloud GmbH
*
@ -35,6 +58,11 @@ const isSubscribed = computed(() => store.apps.find(({ level }) => level === 300
<template>
<!-- Categories & filters -->
<NcAppNavigation :aria-label="t('appstore', 'Appstore categories')">
<template #search>
<NcAppNavigationSearch
v-model="search"
:label="t('appstore', 'Search apps…')" />
</template>
<template #list>
<NcAppNavigationItem
v-if="appstoreEnabled"

15
package-lock.json generated
View file

@ -32,6 +32,7 @@
"color": "^5.0.3",
"debounce": "^3.0.0",
"marked": "^17.0.1",
"p-queue": "^9.2.0",
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"vue": "^3.5.33",
@ -8535,9 +8536,9 @@
"license": "MIT"
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/events": {
@ -12972,12 +12973,12 @@
}
},
"node_modules/p-queue": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz",
"integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"eventemitter3": "^5.0.4",
"p-timeout": "^7.0.0"
},
"engines": {

View file

@ -61,6 +61,7 @@
"color": "^5.0.3",
"debounce": "^3.0.0",
"marked": "^17.0.1",
"p-queue": "^9.2.0",
"pinia": "^3.0.4",
"sortablejs": "^1.15.7",
"vue": "^3.5.33",