]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(app-store): Make the list view an view instead of component
authorFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 10:42:03 +0000 (12:42 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 11:01:28 +0000 (13:01 +0200)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/settings/src/components/AppList.vue [deleted file]
apps/settings/src/components/AppStore/AppStoreBundle.vue [new file with mode: 0644]
apps/settings/src/views/AppStore/AppStoreViewList.vue [new file with mode: 0644]

diff --git a/apps/settings/src/components/AppList.vue b/apps/settings/src/components/AppList.vue
deleted file mode 100644 (file)
index 2372b46..0000000
+++ /dev/null
@@ -1,385 +0,0 @@
-<!--
-  - SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
-  - SPDX-License-Identifier: AGPL-3.0-or-later
--->
-
-<template>
-       <div id="app-content-inner">
-               <div id="apps-list"
-                       class="apps-list"
-                       :class="{
-                               'apps-list--list-view': (useBundleView || useListView),
-                               'apps-list--store-view': useAppStoreView,
-                       }">
-                       <template v-if="useListView">
-                               <div v-if="showUpdateAll" class="apps-list__toolbar">
-                                       {{ n('settings', '%n app has an update available', '%n apps have an update available', counter) }}
-                                       <NcButton v-if="showUpdateAll"
-                                               id="app-list-update-all"
-                                               type="primary"
-                                               @click="updateAll">
-                                               {{ n('settings', 'Update', 'Update all', counter) }}
-                                       </NcButton>
-                               </div>
-
-                               <div v-if="!showUpdateAll" class="apps-list__toolbar">
-                                       {{ t('settings', 'All apps are up-to-date.') }}
-                               </div>
-
-                               <TransitionGroup name="apps-list" tag="table" class="apps-list__list-container">
-                                       <tr key="app-list-view-header">
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
-                                               </th>
-                                       </tr>
-                                       <AppItem v-for="app in apps"
-                                               :key="app.id"
-                                               :app="app"
-                                               :category="category" />
-                               </TransitionGroup>
-                       </template>
-
-                       <table v-if="useBundleView"
-                               class="apps-list__list-container">
-                               <tr key="app-list-view-header">
-                                       <th id="app-table-col-icon">
-                                               <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
-                                       </th>
-                                       <th id="app-table-col-name">
-                                               <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
-                                       </th>
-                                       <th id="app-table-col-version">
-                                               <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
-                                       </th>
-                                       <th id="app-table-col-level">
-                                               <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
-                                       </th>
-                                       <th id="app-table-col-actions">
-                                               <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
-                                       </th>
-                               </tr>
-                               <template v-for="bundle in bundles">
-                                       <tr :key="bundle.id">
-                                               <th :id="`app-table-rowgroup-${bundle.id}`" colspan="5" scope="rowgroup">
-                                                       <div class="apps-list__bundle-heading">
-                                                               <span class="apps-list__bundle-header">
-                                                                       {{ bundle.name }}
-                                                               </span>
-                                                               <NcButton type="secondary" @click="toggleBundle(bundle.id)">
-                                                                       {{ t('settings', bundleToggleText(bundle.id)) }}
-                                                               </NcButton>
-                                                       </div>
-                                               </th>
-                                       </tr>
-                                       <AppItem v-for="app in bundleApps(bundle.id)"
-                                               :key="bundle.id + app.id"
-                                               :use-bundle-view="true"
-                                               :headers="`app-table-rowgroup-${bundle.id}`"
-                                               :app="app"
-                                               :category="category" />
-                               </template>
-                       </table>
-                       <ul v-if="useAppStoreView" class="apps-list__store-container">
-                               <AppItem v-for="app in apps"
-                                       :key="app.id"
-                                       :app="app"
-                                       :category="category"
-                                       :list-view="false" />
-                       </ul>
-               </div>
-
-               <div id="apps-list-search" class="apps-list apps-list--list-view">
-                       <div class="apps-list__list-container">
-                               <table v-if="search !== '' && searchApps.length > 0" class="apps-list__list-container">
-                                       <caption class="apps-list__bundle-header">
-                                               {{ t('settings', 'Results from other categories') }}
-                                       </caption>
-                                       <tr key="app-list-view-header">
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
-                                               </th>
-                                               <th>
-                                                       <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
-                                               </th>
-                                       </tr>
-                                       <AppItem v-for="app in searchApps"
-                                               :key="app.id"
-                                               :app="app"
-                                               :category="category" />
-                               </table>
-                       </div>
-               </div>
-
-               <div v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0" id="apps-list-empty" class="emptycontent emptycontent-search">
-                       <div id="app-list-empty-icon" class="icon-settings-dark" />
-                       <h2>{{ t('settings', 'No apps found for your version') }}</h2>
-               </div>
-       </div>
-</template>
-
-<script>
-import { subscribe, unsubscribe } from '@nextcloud/event-bus'
-import AppItem from './AppList/AppItem.vue'
-import pLimit from 'p-limit'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
-
-export default {
-       name: 'AppList',
-       components: {
-               AppItem,
-               NcButton,
-       },
-
-       props: {
-               category: {
-                       type: String,
-                       required: true,
-               },
-       },
-
-       data() {
-               return {
-                       search: '',
-               }
-       },
-       computed: {
-               counter() {
-                       return this.apps.filter(app => app.update).length
-               },
-               loading() {
-                       return this.$store.getters.loading('list')
-               },
-               hasPendingUpdate() {
-                       return this.apps.filter(app => app.update).length > 0
-               },
-               showUpdateAll() {
-                       return this.hasPendingUpdate && this.useListView
-               },
-               apps() {
-                       const apps = this.$store.getters.getAllApps
-                               .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1)
-                               .sort(function(a, b) {
-                                       const sortStringA = '' + (a.active ? 0 : 1) + (a.update ? 0 : 1) + a.name
-                                       const sortStringB = '' + (b.active ? 0 : 1) + (b.update ? 0 : 1) + b.name
-                                       return OC.Util.naturalSortCompare(sortStringA, sortStringB)
-                               })
-
-                       if (this.category === 'installed') {
-                               return apps.filter(app => app.installed)
-                       }
-                       if (this.category === 'enabled') {
-                               return apps.filter(app => app.active && app.installed)
-                       }
-                       if (this.category === 'disabled') {
-                               return apps.filter(app => !app.active && app.installed)
-                       }
-                       if (this.category === 'app-bundles') {
-                               return apps.filter(app => app.bundles)
-                       }
-                       if (this.category === 'updates') {
-                               return apps.filter(app => app.update)
-                       }
-                       if (this.category === 'supported') {
-                               // For customers of the Nextcloud GmbH the app level will be set to `300` for apps that are supported in their subscription
-                               return apps.filter(app => app.level === 300)
-                       }
-                       if (this.category === 'featured') {
-                               // An app level of `200` will be set for apps featured on the app store
-                               return apps.filter(app => app.level === 200)
-                       }
-
-                       // filter app store categories
-                       return apps.filter(app => {
-                               return app.appstore && app.category !== undefined
-                                       && (app.category === this.category || app.category.indexOf(this.category) > -1)
-                       })
-               },
-               bundles() {
-                       return this.$store.getters.getAppBundles.filter(bundle => this.bundleApps(bundle.id).length > 0)
-               },
-               bundleApps() {
-                       return function(bundle) {
-                               return this.$store.getters.getAllApps
-                                       .filter(app => {
-                                               return app.bundleIds !== undefined && app.bundleIds.includes(bundle)
-                                       })
-                       }
-               },
-               searchApps() {
-                       if (this.search === '') {
-                               return []
-                       }
-                       return this.$store.getters.getAllApps
-                               .filter(app => {
-                                       if (app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) {
-                                               return (!this.apps.find(_app => _app.id === app.id))
-                                       }
-                                       return false
-                               })
-               },
-               useAppStoreView() {
-                       return !this.useListView && !this.useBundleView
-               },
-               useListView() {
-                       return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates' || this.category === 'featured' || this.category === 'supported')
-               },
-               useBundleView() {
-                       return (this.category === 'app-bundles')
-               },
-               allBundlesEnabled() {
-                       return (id) => {
-                               return this.bundleApps(id).filter(app => !app.active).length === 0
-                       }
-               },
-               bundleToggleText() {
-                       return (id) => {
-                               if (this.allBundlesEnabled(id)) {
-                                       return t('settings', 'Disable all')
-                               }
-                               return t('settings', 'Download and enable all')
-                       }
-               },
-       },
-
-       beforeDestroy() {
-               unsubscribe('nextcloud:unified-search.search', this.setSearch)
-               unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
-       },
-
-       mounted() {
-               subscribe('nextcloud:unified-search.search', this.setSearch)
-               subscribe('nextcloud:unified-search.reset', this.resetSearch)
-       },
-
-       methods: {
-               setSearch({ query }) {
-                       this.search = query
-               },
-               resetSearch() {
-                       this.search = ''
-               },
-               toggleBundle(id) {
-                       if (this.allBundlesEnabled(id)) {
-                               return this.disableBundle(id)
-                       }
-                       return this.enableBundle(id)
-               },
-               enableBundle(id) {
-                       const apps = this.bundleApps(id).map(app => app.id)
-                       this.$store.dispatch('enableApp', { appId: apps, groups: [] })
-                               .catch((error) => {
-                                       console.error(error)
-                                       OC.Notification.show(error)
-                               })
-               },
-               disableBundle(id) {
-                       const apps = this.bundleApps(id).map(app => app.id)
-                       this.$store.dispatch('disableApp', { appId: apps, groups: [] })
-                               .catch((error) => {
-                                       OC.Notification.show(error)
-                               })
-               },
-               updateAll() {
-                       const limit = pLimit(1)
-                       this.apps
-                               .filter(app => app.update)
-                               .map(app => limit(() => this.$store.dispatch('updateApp', { appId: app.id })),
-                               )
-               },
-       },
-}
-</script>
-
-<style lang="scss" scoped>
-$toolbar-padding: 8px;
-$toolbar-height: 44px + $toolbar-padding * 2;
-
-.apps-list {
-       display: flex;
-       flex-wrap: wrap;
-       align-content: flex-start;
-
-       // For transition group
-       &--move {
-               transition: transform 1s;
-       }
-
-       #app-list-update-all {
-               margin-inline-start: 10px;
-       }
-
-       &__toolbar {
-               height: $toolbar-height;
-               padding: $toolbar-padding;
-               // Leave room for app-navigation-toggle
-               padding-inline-start: $toolbar-height;
-               width: 100%;
-               background-color: var(--color-main-background);
-               position: sticky;
-               top: 0;
-               z-index: 1;
-               display: flex;
-               align-items: center;
-       }
-
-       &--list-view {
-               margin-bottom: 100px;
-               // For positioning link overlay on rows
-               position: relative;
-       }
-
-       &__list-container {
-               width: 100%;
-       }
-
-       &__store-container {
-               display: flex;
-               flex-wrap: wrap;
-       }
-
-       &__bundle-heading {
-               display: flex;
-               align-items: center;
-               margin-block: 20px;
-               margin-inline: 0 10px;
-       }
-
-       &__bundle-header {
-               margin-block: 0;
-               margin-inline: 50px 10px;
-               font-weight: bold;
-               font-size: 20px;
-               line-height: 30px;
-               color: var(--color-text-light);
-       }
-}
-
-#apps-list-search {
-       .app-item {
-               h2 {
-                       margin-bottom: 0;
-               }
-       }
-}
-</style>
diff --git a/apps/settings/src/components/AppStore/AppStoreBundle.vue b/apps/settings/src/components/AppStore/AppStoreBundle.vue
new file mode 100644 (file)
index 0000000..ef2eee6
--- /dev/null
@@ -0,0 +1,154 @@
+<script setup lang="ts">
+import type { IAppStoreApp } from '../../constants/AppStoreTypes'
+
+import { showError, showSuccess } from '@nextcloud/dialogs'
+import { t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import { useAppStore } from '../../store/appStore'
+import { AppStoreSearchResultsCategory } from '../../constants/AppStoreConstants'
+import { useAppStoreSearchStore } from '../../store/appStoreSearch'
+import { useCurrentCategory } from '../../composables/useCurrentCategory'
+import { disableApps, enableApps } from '../../service/AppStoreApi'
+import logger from '../../logger'
+import AppItem from './AppItem/AppItem.vue'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+
+// with Vue 3 we can use IAppStoreBundle type to define props
+const props = defineProps({
+       id: {
+               type: String,
+               required: true,
+       },
+       name: {
+               type: String,
+               required: true,
+       },
+       isCategory: {
+               type: Boolean,
+               default: false,
+       },
+       showHeading: {
+               type: Boolean,
+               default: false,
+       },
+})
+
+const store = useAppStore()
+const searchStore = useAppStoreSearchStore()
+const { currentCategory } = useCurrentCategory()
+
+/**
+ * All apps that are part of this bundle
+ */
+const bundleApps = computed(() => {
+       if (props.id === AppStoreSearchResultsCategory) {
+               // if we show the search results only show the external results not the ones from the current category
+               return searchStore.searchResults
+                       .filter((app) => app.category !== currentCategory.value && !app.category.includes(currentCategory.value))
+       }
+
+       let apps = [] as IAppStoreApp[]
+       if (props.isCategory) {
+               apps = store.getAppsByCategory(currentCategory.value)
+       } else {
+               apps = store.getAppsByBundle(props.id)
+       }
+
+       // filter current bundle if needed
+       if (searchStore.query !== '') {
+               return apps.filter(searchStore.filterAppsByQuery)
+       }
+       return apps.sort((a, b) => a.name.localeCompare(b.name))
+})
+
+/** True if all apps of this bundle have been enabled */
+const allAppsEnabled = computed(() => bundleApps.value.every((app) => app.active))
+
+/**
+ * Toggle all apps of this bundle.
+ *   If *all* apps are installed and enabled -> Disable all
+ *   Otherwise install and enable all missing apps
+ */
+async function toggleBundle() {
+       try {
+               if (allAppsEnabled.value) {
+                       await disableApps(bundleApps.value.map((app) => app.id))
+                       showSuccess(t('settings', 'All apps of {bundle} were disabled.', { bundle: props.name }))
+               } else {
+                       // Get all ids of apps that are not enabled currently
+                       const appIds = bundleApps.value
+                               .filter((app) => !app.active)
+                               .map((app) => app.id)
+                       await enableApps(appIds)
+                       showSuccess(t('settings', 'All apps of {bundle} were installed.', { bundle: props.name }))
+               }
+       } catch (error) {
+               logger.error('Could not enable all apps of bundle.', { bundle: props.id, error })
+               showError(t('settings', 'Installing {bundle} failed', { bundle: props.name }))
+       }
+}
+</script>
+<template>
+       <TransitionGroup v-if="bundleApps.length > 0"
+               class="app-store-bundle"
+               name="app-store-bundle"
+               tag="tbody">
+               <tr v-if="showHeading" :key="`${id}--header`">
+                       <th :id="`app-table-rowgroup-${id}`"
+                               class="app-store-bundle__header"
+                               colspan="5"
+                               scope="rowgroup">
+                               <div class="app-store-bundle__header-wrapper">
+                                       <span class="app-store-bundle__header-text">
+                                               {{ name }}
+                                       </span>
+                                       <NcButton v-if="!isCategory"
+                                               type="primary"
+                                               @click="toggleBundle">
+                                               {{ allAppsEnabled ? t('settings', 'Disable all') : t('settings', 'Download and enable all') }}
+                                       </NcButton>
+                               </div>
+                       </th>
+               </tr>
+               <AppItem v-for="app in bundleApps"
+                       :key="`${id}-${app.id}`"
+                       :app="app"
+                       :category="currentCategory"
+                       :headers="showHeading ? `app-table-rowgroup-${id}` : undefined"
+                       list-view />
+       </TransitionGroup>
+</template>
+
+<style scoped lang="scss">
+.app-store-bundle {
+       &__header {
+               height: var(--app-item-height);
+
+               &:hover {
+                       background-color: var(--color-main-background);
+               }
+       }
+
+       &__header-wrapper {
+               display: flex;
+               flex-direction: row;
+               justify-content: space-between;
+               // align with icons
+               padding-inline-start: calc((var(--default-clickable-area) - 20px) / 2);
+       }
+
+       &__header-text {
+               font-size: 1.6em;
+       }
+
+       // Add some spacing before any additional bundle
+       &:not(:first-of-type) &__header {
+               height: calc(2 * var(--app-item-height)) !important;
+               vertical-align: bottom;
+
+               .app-store-bundle__header-wrapper {
+                       padding-bottom: calc(2 * var(--default-grid-baseline));
+               }
+       }
+}
+</style>
diff --git a/apps/settings/src/views/AppStore/AppStoreViewList.vue b/apps/settings/src/views/AppStore/AppStoreViewList.vue
new file mode 100644 (file)
index 0000000..5dd693d
--- /dev/null
@@ -0,0 +1,138 @@
+<script setup lang="ts">
+import type { IAppStoreBundle } from '../../constants/AppStoreTypes'
+
+import { loadState } from '@nextcloud/initial-state'
+import { n, t } from '@nextcloud/l10n'
+import { computed } from 'vue'
+import { useRoute } from 'vue-router/composables'
+import { useAppStore } from '../../store/appStore'
+import { useCurrentCategory } from '../../composables/useCurrentCategory'
+import { useAppStoreSearchStore } from '../../store/appStoreSearch'
+import { AppStoreSearchResultsCategory } from '../../constants/AppStoreConstants'
+import { useAppManagement } from '../../composables/useAppManagement'
+import AppStoreBundle from '../../components/AppStore/AppStoreBundle.vue'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
+
+const appstoreBundles = loadState<IAppStoreBundle[]>('settings', 'appstoreBundles', [])
+
+const route = useRoute()
+const store = useAppStore()
+const searchStore = useAppStoreSearchStore()
+const { updateApp } = useAppManagement()
+const {
+       currentCategory,
+       currentCategoryName,
+} = useCurrentCategory()
+
+const isBundleView = computed(() => route.params.category === 'app-bundles')
+/**
+ * If we are not in the bundle view we just fake one bundle containing all apps.
+ * Also if there are search results from other categories, we show it as a different bundle.
+ */
+const bundles = computed(() => {
+       const appBundles: IAppStoreBundle[] = isBundleView.value
+               ? appstoreBundles
+               : [{ id: currentCategory.value, name: currentCategoryName.value, isCategory: true }]
+       if (searchStore) {
+               appBundles.push({
+                       id: AppStoreSearchResultsCategory,
+                       name: t('settings', 'Results from other categories'),
+               })
+       }
+       return appBundles
+})
+
+/**
+ * True if the update notification should be shown (only when not in bundle or search view)
+ */
+const showUpdateNotification = computed(() => !isBundleView.value && searchStore.query === '')
+/**
+ * List of apps in the current view with updates available.
+ */
+const updatesAvailable = computed(() => showUpdateNotification.value
+       ? store.getAppsByCategory(currentCategory.value).filter((app) => app.update)
+       : [],
+)
+
+/**
+ * Update all apps that need an update
+ */
+async function updateAll() {
+       for (const app of updatesAvailable.value) {
+               await updateApp(app)
+       }
+}
+</script>
+
+<template>
+       <div class="app-store-view-list">
+               <!-- Show the update notification if it makes sense for the current view-->
+               <template v-if="showUpdateNotification">
+                       <NcNoteCard v-if="updatesAvailable.length === 0"
+                               :text="t('settings', 'All apps are up-to-date.')"
+                               type="info" />
+                       <NcNoteCard v-else type="info">
+                               <div class="app-store-view-list__update-notification">
+                                       <span>
+                                               {{ n('settings', '%n app has an update available', '%n apps have an update available', updatesAvailable.length) }}
+                                       </span>
+                                       <NcButton class="app-store-view-list__update-notification-button"
+                                               type="primary"
+                                               @click="updateAll">
+                                               {{ n('settings', 'Update', 'Update all', updatesAvailable.length) }}
+                                       </NcButton>
+                               </div>
+                       </NcNoteCard>
+               </template>
+
+               <table class="app-store-view-list__table">
+                       <tr key="app-list-view-header">
+                               <th id="app-table-col-icon">
+                                       <span class="hidden-visually">{{ t('settings', 'Icon') }}</span>
+                               </th>
+                               <th id="app-table-col-name">
+                                       <span class="hidden-visually">{{ t('settings', 'Name') }}</span>
+                               </th>
+                               <th id="app-table-col-version">
+                                       <span class="hidden-visually">{{ t('settings', 'Version') }}</span>
+                               </th>
+                               <th id="app-table-col-level">
+                                       <span class="hidden-visually">{{ t('settings', 'Level') }}</span>
+                               </th>
+                               <th id="app-table-col-actions">
+                                       <span class="hidden-visually">{{ t('settings', 'Actions') }}</span>
+                               </th>
+                       </tr>
+                       <AppStoreBundle v-for="bundle, index in bundles"
+                               v-bind="bundle"
+                               :key="bundle.id"
+                               :show-heading="bundles.length > 2 || index > 0" />
+               </table>
+       </div>
+</template>
+
+<style scoped lang="scss">
+.app-store-view-list {
+       --app-item-padding: calc(var(--default-grid-baseline) * 2);
+       --app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
+
+       padding-inline: var(--app-navigation-padding);
+
+       &__table {
+               width: 100%;
+       }
+
+       &__update-notification {
+               align-items: center;
+               display: flex;
+               flex-direction: row;
+               justify-content: space-between;
+               width: 100%;
+
+               &-button {
+                       margin-inline-start: 2em;
+               }
+       }
+}
+</style>