+++ /dev/null
-<!--
- - 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>
--- /dev/null
+<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>
--- /dev/null
+<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>