]> source.dussan.org Git - nextcloud-server.git/commitdiff
refactor(app-store): Split app item into smaller components
authorFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 10:40:48 +0000 (12:40 +0200)
committerFerdinand Thiessen <opensource@fthiessen.de>
Wed, 23 Oct 2024 11:01:24 +0000 (13:01 +0200)
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
apps/settings/lib/Controller/AppSettingsController.php
apps/settings/src/components/AppStore/AppItem/AppItem.vue
apps/settings/src/components/AppStore/AppItem/AppItemActions.vue [new file with mode: 0644]
apps/settings/src/components/AppStore/AppItem/AppItemIcon.vue
apps/settings/src/components/AppStore/AppItem/AppItemName.vue
apps/settings/src/components/AppStore/AppItem/StatefulButton.vue [new file with mode: 0644]

index 7a1e5dd71571e7e1e3349d5d74d012553efbbea1..b6cd7884af0c2a74d995ee812f8135df462e1800 100644 (file)
@@ -441,9 +441,9 @@ class AppSettingsController extends Controller {
                                'canInstall' => true,
                                'screenshot' => isset($app['screenshots'][0]['url']) ? 'https://usercontent.apps.nextcloud.com/' . base64_encode($app['screenshots'][0]['url']) : '',
                                'score' => $app['ratingOverall'],
+                               'ratingRecent' => $app['ratingRecent'],
                                'ratingNumOverall' => $app['ratingNumOverall'],
-                               'ratingNumThresholdReached' => $app['ratingNumOverall'] > 5,
-                               'removable' => $existsLocally,
+                               'ratingNumRecent' => $app['ratingNumRecent'],
                                'active' => $this->appManager->isEnabledForUser($app['id']),
                                'needsDownload' => !$existsLocally,
                                'groups' => $groups,
index 09dc8f89f23d655f3e429821eb23890b40415d2a..8937a69dda1b3725bdbbd068eb13c2984797f0e8 100644 (file)
@@ -9,7 +9,6 @@
                        'app-item--list-view': listView,
                        'app-item--store-view': !listView,
                        'app-item--selected': isSelected,
-                       'app-item--with-sidebar': withSidebar,
                }">
                <AppItemIcon :app="app"
                        :list-view="listView"
 
                <component :is="dataItemTag"
                        v-if="!listView"
-                       class="app-summary"
+                       class="app-item__summary"
                        :headers="getDataItemHeaders(`app-version`)">
                        {{ app.summary }}
                </component>
 
                <component :is="dataItemTag"
                        v-if="listView"
-                       class="app-version"
+                       v-show="!isSmallMobile"
+                       class="app-item__version"
                        :headers="getDataItemHeaders(`app-table-col-version`)">
                        <span v-if="app.version">{{ app.version }}</span>
-                       <span v-else-if="app.appstoreData.releases[0].version">{{ app.appstoreData.releases[0].version }}</span>
+                       <span v-else-if="app.releases?.[0].version">{{ app?.releases[0].version }}</span>
                </component>
 
-               <component :is="dataItemTag" :headers="getDataItemHeaders(`app-table-col-level`)" class="app-level">
+               <component
+                       :is="dataItemTag"
+                       v-show="!listView || !isSmallMobile"
+                       class="app-item__level"
+                       :headers="getDataItemHeaders(`app-table-col-level`)">
                        <AppLevelBadge :level="app.level" />
-                       <AppScore v-if="hasRating && !listView" :score="app.score" />
+                       <AppScore v-if="appRating && !listView" :score="appRating" />
                </component>
 
                <AppItemActions v-if="!inline"
-                       v-show="!isSmallMobile"
                        :app="app"
+                       :data-item-tag="dataItemTag"
+                       :list-view="listView"
                        :headers="getDataItemHeaders('app-table-col-actions')" />
        </component>
 </template>
 
 <script setup lang="ts">
 import type { PropType } from 'vue'
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
 
 import { useIsSmallMobile } from '@nextcloud/vue/dist/Composables/useIsMobile.js'
 import { useRoute } from 'vue-router/composables'
-import { computed } from 'vue'
+import { computed, toRef } from 'vue'
+import { useAppRating } from '../../../composables/useAppRating'
 
 import AppItemActions from './AppItemActions.vue'
 import AppItemIcon from './AppItemIcon.vue'
@@ -67,7 +73,7 @@ const props = defineProps({
         * The app to show
         */
        app: {
-               type: Object as PropType<IAppstoreApp>,
+               type: Object as PropType<IAppStoreApp>,
                required: true,
        },
 
@@ -115,28 +121,17 @@ const props = defineProps({
 
 const route = useRoute()
 const isSmallMobile = useIsSmallMobile()
+const appRating = useAppRating(toRef(props, 'app'))
 
 /**
  * The HTML tag to use.
  */
 const dataItemTag = computed(() => props.listView ? 'td' : 'div')
 
-/**
- * If the rating should be shown.
- * This is true if at least five rating were sent.
- */
-const hasRating = computed(() => props.app.appstoreData?.ratingNumOverall > 5)
-
-/**
- * Is the sidebar shown.
- * The sidebar is always shown if an app is selected.
- */
-const withSidebar = computed(() => Boolean(route.params.id))
-
 /**
  * Is this app is the currently selected app
  */
-const isSelected = computed(() => route.params.id === props.app.id)
+const isSelected = computed(() => route.params.appId === props.app.id)
 
 /**
  * Set table header association to a table cell.
@@ -150,10 +145,8 @@ function getDataItemHeaders(columnName: string) {
 </script>
 
 <style scoped lang="scss">
-@use '../../../../../../core/css/variables.scss' as variables;
-@use 'sass:math';
-
 .app-item {
+       box-sizing: border-box;
        position: relative;
 
        &:hover {
@@ -161,9 +154,6 @@ function getDataItemHeaders(columnName: string) {
        }
 
        &--list-view {
-               --app-item-padding: calc(var(--default-grid-baseline) * 2);
-               --app-item-height: calc(var(--default-clickable-area) + var(--app-item-padding) * 2);
-
                &.app-item--selected {
                        background-color: var(--color-background-dark);
                }
@@ -174,62 +164,21 @@ function getDataItemHeaders(columnName: string) {
                        padding: var(--app-item-padding);
                        height: var(--app-item-height);
                }
-
-               /* hide app version and level on narrower screens */
-               @media only screen and (max-width: 900px) {
-                       .app-version,
-                       .app-level {
-                               display: none;
-                       }
-               }
        }
 
        &--store-view {
-               padding: 30px;
-
-               @media only screen and (min-width: 1601px) {
-                       width: 25%;
+               border-radius: var(--border-radius-container);
+               padding: calc(3 * var(--default-grid-baseline));
+               display: flex;
+               flex-direction: column;
 
-                       &.app-item--with-sidebar {
-                               width: 33%;
-                       }
-               }
-
-               @media only screen and (max-width: 1600px) {
-                       width: 25%;
-
-                       &.app-item--with-sidebar {
-                               width: 33%;
-                       }
-               }
-
-               @media only screen and (max-width: 1400px) {
-                       width: 33%;
-
-                       &.app-item--with-sidebar {
-                               width: 50%;
-                       }
-               }
-
-               @media only screen and (max-width: 900px) {
-                       width: 50%;
-
-                       &.app-item--with-sidebar {
-                               width: 100%;
-                       }
-               }
-
-               @media only screen and (max-width: variables.$breakpoint-mobile) {
-                       width: 50%;
-               }
-
-               @media only screen and (max-width: 480px) {
-                       width: 100%;
+               &.app-item--selected {
+                       outline: 2px solid var(--color-main-text);
                }
        }
 }
 
-.app-version {
+.app-item__version {
        color: var(--color-text-maxcontrast);
 }
 </style>
diff --git a/apps/settings/src/components/AppStore/AppItem/AppItemActions.vue b/apps/settings/src/components/AppStore/AppItem/AppItemActions.vue
new file mode 100644 (file)
index 0000000..282a7ec
--- /dev/null
@@ -0,0 +1,99 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+import { t } from '@nextcloud/l10n'
+import { ref, toRef } from 'vue'
+import { useAppManagement } from '../../../composables/useAppManagement'
+import StatefulButton from './StatefulButton.vue'
+import { useAppState } from '../../../composables/useAppState'
+
+const props = defineProps<{
+       app: IAppStoreApp
+       dataItemTag: 'td'|'div'
+       listView?: boolean
+}>()
+
+const {
+       canInstall,
+       canUninstall,
+} = useAppState(toRef(props, 'app'))
+
+const {
+       disableApp,
+       enableApp,
+       forceEnable,
+       updateApp,
+       uninstallApp,
+} = useAppManagement()
+
+const isLoading = ref(false)
+</script>
+
+<template>
+       <component :is="dataItemTag"
+               class="app-item-actions"
+               :class="{ 'app-item-actions--grid': !listView }">
+               <div class="app-item-actions__wrapper">
+                       <StatefulButton v-if="app.update"
+                               :disabled="isLoading"
+                               type="primary"
+                               @click="updateApp(app)">
+                               {{ t('settings', 'Update to {update}', { update: app.update }) }}
+                       </StatefulButton>
+                       <StatefulButton v-if="canUninstall"
+                               class="app-item-actions__uninstall"
+                               :disabled="isLoading"
+                               type="tertiary"
+                               @click="uninstallApp(app)">
+                               {{ t('settings', 'Uninstall') }}
+                       </StatefulButton>
+                       <StatefulButton v-if="app.active"
+                               :disabled="isLoading"
+                               @click="disableApp(app)">
+                               {{ t('settings', 'Disable') }}
+                       </StatefulButton>
+                       <StatefulButton v-if="!app.active && canInstall"
+                               :title="app.needsDownload && !isLoading ? t('settings', 'The app will be downloaded from the App Store') : undefined"
+                               :type="listView ? 'secondary' : 'primary'"
+                               :disabled="isLoading"
+                               @click="enableApp(app)">
+                               {{ app.needsDownload ? t('settings', 'Download and enable') : t('settings', 'Enable') }}
+                       </StatefulButton>
+                       <StatefulButton v-else-if="!app.active && !app.isCompatible"
+                               type="tertiary"
+                               :disabled="isLoading"
+                               @click="forceEnable(app)">
+                               {{ t('settings', 'Allow untested app') }}
+                       </StatefulButton>
+               </div>
+       </component>
+</template>
+
+<style scoped lang="scss">
+.app-item-actions {
+       &--grid {
+               align-self: end;
+               margin-block-start: auto;
+
+               .app-item-actions__wrapper {
+                       flex-wrap: wrap;
+               }
+       }
+
+       &__wrapper {
+               display: flex;
+               flex-direction: row;
+               gap: var(--default-grid-baseline);
+               justify-content: end;
+               width: 100%;
+       }
+
+       &__uninstall {
+               color: var(--color-error-text);
+       }
+}
+</style>
index 44828a49451de06c2f493c2a88317ea463df03b6..27e10998a40c1c9d1a73f2c96572fd1ef03d95f3 100644 (file)
@@ -4,15 +4,16 @@
 -->
 
 <script setup lang="ts">
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes.ts'
 import { computed, ref, watchEffect } from 'vue'
 import IconSettings from 'vue-material-design-icons/Cog.vue'
 
 import { preloadImage } from '../../../service/imagePreloading.ts'
+import logger from '../../../logger.ts';
 
 const props = defineProps<{
-       app: IAppstoreApp
-       listView: boolean
+       app: IAppStoreApp
+       listView?: boolean
 }>()
 
 /**
@@ -44,6 +45,7 @@ watchEffect(() => {
        if (hasPreview.value) {
                preloadImage(previewUrl.value!)
                        .then(() => { previewLoaded.value = true })
+                       .catch((error) => logger.debug(`Failed to load screenshot for ${props.app.name}`, { error }))
        }
 })
 
@@ -68,7 +70,9 @@ const tag = computed(() => props.listView ? 'td' : 'div')
 
 <style scoped lang="scss">
 .app-item-icon {
-       height: auto;
+       --app-icon-size: 20px;
+
+       height: var(--app-icon-size);
        width: var(--default-clickable-area);
        position: relative;
        overflow: hidden;
@@ -78,8 +82,8 @@ const tag = computed(() => props.listView ? 'td' : 'div')
        }
 
        .app-item-icon__image {
-               height: 20px;
-               width: 20px;
+               height: var(--app-icon-size);
+               width: var(--app-icon-size);
 
                &--is-icon {
                        // if an icon is shown we need to adjust the color if needed
@@ -89,12 +93,12 @@ const tag = computed(() => props.listView ? 'td' : 'div')
        }
 
        &--grid {
-               height: 150px;
+               --app-icon-size: 150px;
                width: auto;
 
                .app-item-icon__image,
                .app-item-icon__fallback {
-                       height: 150px;
+                       height: var(--app-icon-size);
                        width: 100%;
                        object-fit: cover;
                }
index 8270cc1898af9611f3f580593f8d0e3c83923205..062419f5e491b820914da3e858ed2c3b19a2f4b6 100644 (file)
@@ -4,31 +4,32 @@
 -->
 
 <script setup lang="ts">
-import type { IAppstoreApp } from '../../../app-types'
+import type { IAppStoreApp } from '../../../constants/AppStoreTypes'
+
 import { computed } from 'vue'
+import { useRoute } from 'vue-router/composables'
 
 const props = defineProps<{
-       app: IAppstoreApp
+       app: IAppStoreApp
        category: string
        listView: boolean
 }>()
 
+const route = useRoute()
+
 /**
  * The HTML tag to use - depending on the list vs grid view
  */
 const tag = computed(() => props.listView ? 'td' : 'div')
+const to = computed(() => route.name === 'discover'
+       ? { name: 'discover', params: { appId: props.app.id } }
+       : { name: 'app-details', params: { category: props.category, appId: props.app.id } },
+)
 </script>
 
 <template>
        <component :is="tag" class="app-item-name">
-               <router-link class="app-item-name__link"
-                       :to="{
-                               name: 'apps-details',
-                               params: {
-                                       category: category,
-                                       id: app.id
-                               },
-                       }">
+               <router-link class="app-item-name__link" :to="to">
                        {{ app.name }}
                </router-link>
        </component>
@@ -36,6 +37,7 @@ const tag = computed(() => props.listView ? 'td' : 'div')
 
 <style scoped lang="scss">
 .app-item-name {
+       font-weight: bold;
        margin: calc(2 * var(--default-grid-baseline)) 0;
 
        &__link::after {
@@ -47,6 +49,7 @@ const tag = computed(() => props.listView ? 'td' : 'div')
 
        // The list view
        &--list-view {
+               font-weight: normal;
                margin: 0;
                padding: 0 var(--app-item-padding);
 
diff --git a/apps/settings/src/components/AppStore/AppItem/StatefulButton.vue b/apps/settings/src/components/AppStore/AppItem/StatefulButton.vue
new file mode 100644 (file)
index 0000000..7e7ce38
--- /dev/null
@@ -0,0 +1,48 @@
+<!--
+  - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+  - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<script setup lang="ts">
+/**
+ * This component provides a simple wrapper over the NcButton to allow accessible disabled state.
+ * @module StatefulButton
+ */
+import { t } from '@nextcloud/l10n'
+import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+
+const props = defineProps<{
+       disabled: boolean
+}>()
+
+const emit = defineEmits(['click'])
+
+/**
+ * Only forward the click event if not disabled
+ * @param args potential event parameters
+ */
+function onClick(...args) {
+       if (!props.disabled) {
+               emit('click', ...args)
+       }
+}
+</script>
+
+<template>
+       <NcButton :aria-disabled="disabled ? 'true' : undefined"
+               :class="{ 'soft-disabled': disabled }"
+               v-bind="$attrs"
+               @click.stop="onClick">
+               <slot />
+               <span v-if="disabled" class="hidden-visually">
+                       {{ t('settings', '(app is loading)') }}
+               </span>
+       </NcButton>
+</template>
+
+<style scoped>
+.soft-disabled {
+       cursor: not-allowed;
+       opacity: .7;
+}
+</style>