feat(settings): Implement new app discover section for app management

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/44129/head
Ferdinand Thiessen 3 months ago committed by Benjamin Gaussorgues
parent 072393d017
commit 4cadb82850
No known key found for this signature in database
GPG Key ID: 5DAC1CAFAA6DB883

@ -0,0 +1,96 @@
<template>
<div class="app-discover">
<NcEmptyContent v-if="hasError"
:name="t('settings', 'Nothing to show')"
:description="t('settings', 'Could not load section content from app store.')">
<template #icon>
<NcIconSvgWrapper :path="mdiEyeOff" :size="64" />
</template>
</NcEmptyContent>
<NcEmptyContent v-else-if="elements.length === 0"
:name="t('settings', 'Loading')"
:description="t('settings', 'Fetching the latest news…')">
<template #icon>
<NcLoadingIcon :size="64" />
</template>
</NcEmptyContent>
<template v-else>
<component :is="getComponent(entry.type)"
v-for="entry, index in elements"
:key="entry.id ?? index"
v-bind="entry" />
</template>
</div>
</template>
<script setup lang="ts">
import type { IAppDiscoverElements } from '../../constants/AppDiscoverTypes.ts'
import { mdiEyeOff } from '@mdi/js'
import { showError } from '@nextcloud/dialogs'
import { translate as t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { defineAsyncComponent, defineComponent, onBeforeMount, ref } from 'vue'
import axios from '@nextcloud/axios'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import logger from '../../logger'
const PostType = defineAsyncComponent(() => import('./PostType.vue'))
const hasError = ref(false)
const elements = ref<IAppDiscoverElements[]>([])
/**
* Shuffle using the Fisher-Yates algorithm
* @param array The array to shuffle (in place)
*/
const shuffleArray = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]]
}
return array
}
/**
* Load the app discover section information
*/
onBeforeMount(async () => {
try {
const { data } = await axios.get<IAppDiscoverElements[]>(generateUrl('/settings/api/apps/discover'))
elements.value = shuffleArray(data)
} catch (error) {
hasError.value = true
logger.error(error as Error)
showError(t('settings', 'Could not load app discover section'))
}
})
const getComponent = (type) => {
if (type === 'post') {
return PostType
}
return defineComponent({
mounted: () => logger.error('Unknown component requested ', type),
render: (h) => h('div', t('settings', 'Could not render element')),
})
}
</script>
<style scoped lang="scss">
.app-discover {
max-width: 1008px; /* 900px + 2x 54px padding for the carousel controls */
margin-inline: auto;
padding-inline: 54px;
/* Padding required to make last element not bound to the bottom */
padding-block-end: var(--default-clickable-area, 44px);
display: flex;
flex-direction: column;
gap: var(--default-clickable-area, 44px);
}
</style>

@ -0,0 +1,81 @@
<template>
<article class="app-discover-post"
:class="{ 'app-discover-post--reverse': media && media.alignment === 'start' }">
<div v-if="headline || text" class="app-discover-post__text">
<h3>{{ translatedHeadline }}</h3>
<p>{{ translatedText }}</p>
</div>
<div v-if="media">
<img class="app-discover-post__media" :alt="mediaAlt" :src="mediaSource">
</div>
</article>
</template>
<script setup lang="ts">
import { getLanguage } from '@nextcloud/l10n'
import { computed } from 'vue'
type ILocalizedValue<T> = Record<string, T | undefined> & { en: T }
const props = defineProps<{
type: string
headline: ILocalizedValue<string>
text: ILocalizedValue<string>
link?: string
media: {
alignment: 'start'|'end'
content: ILocalizedValue<{ src: string, alt: string}>
}
}>()
const language = getLanguage()
const getLocalizedValue = <T, >(dict: ILocalizedValue<T>) => dict[language] ?? dict[language.split('_')[0]] ?? dict.en
const translatedText = computed(() => getLocalizedValue(props.text))
const translatedHeadline = computed(() => getLocalizedValue(props.headline))
const localizedMedia = computed(() => getLocalizedValue(props.media.content))
const mediaSource = computed(() => localizedMedia.value?.src)
const mediaAlt = ''
</script>
<style scoped lang="scss">
.app-discover-post {
width: 100%;
background-color: var(--color-primary-element-light);
border-radius: var(--border-radius-rounded);
display: flex;
flex-direction: row;
&--reverse {
flex-direction: row-reverse;
}
h3 {
font-size: 24px;
font-weight: 600;
margin-block: 0 1em;
}
&__text {
padding: var(--border-radius-rounded);
}
&__media {
max-height: 300px;
max-width: 450px;
border-radius: var(--border-radius-rounded);
border-end-start-radius: 0;
border-start-start-radius: 0;
}
&--reverse &__media {
border-radius: var(--border-radius-rounded);
border-end-end-radius: 0;
border-start-end-radius: 0;
}
}
</style>

@ -24,6 +24,7 @@ import { translate as t } from '@nextcloud/l10n'
/** Enum of verification constants, according to Apps */
export const APPS_SECTION_ENUM = Object.freeze({
discover: t('settings', 'Discover'),
installed: t('settings', 'Your apps'),
enabled: t('settings', 'Active apps'),
disabled: t('settings', 'Disabled apps'),

@ -39,6 +39,7 @@ import {
mdiOpenInApp,
mdiSecurity,
mdiStar,
mdiStarCircleOutline,
mdiStarShooting,
mdiTools,
mdiViewDashboard,
@ -49,6 +50,7 @@ import {
*/
export default Object.freeze({
// system special categories
discover: mdiStarCircleOutline,
installed: mdiAccount,
enabled: mdiCheck,
disabled: mdiClose,

@ -24,8 +24,11 @@
<template>
<!-- Apps list -->
<NcAppContent class="app-settings-content"
:page-heading="pageHeading">
<NcEmptyContent v-if="isLoading"
:page-heading="appStoreLabel">
<h2 class="app-settings-content__label" v-text="viewLabel" />
<AppStoreDiscoverSection v-if="currentCategory === 'discover'" />
<NcEmptyContent v-else-if="isLoading"
class="empty-content__loading"
:name="t('settings', 'Loading app list')">
<template #icon>
@ -38,36 +41,31 @@
<script setup lang="ts">
import { translate as t } from '@nextcloud/l10n'
import { computed, getCurrentInstance, onBeforeMount, watch } from 'vue'
import { computed, getCurrentInstance, onBeforeMount, watchEffect } from 'vue'
import { useRoute } from 'vue-router/composables'
import { APPS_SECTION_ENUM } from '../constants/AppsConstants.js'
import { useAppsStore } from '../store/apps-store'
import { APPS_SECTION_ENUM } from '../constants/AppsConstants'
import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import AppList from '../components/AppList.vue'
import AppStoreDiscoverSection from '../components/AppStoreDiscover/AppStoreDiscoverSection.vue'
const route = useRoute()
const store = useAppsStore()
/**
* ID of the current active category, default is `installed`
* ID of the current active category, default is `discover`
*/
const currentCategory = computed(() => route.params?.category ?? 'installed')
const currentCategory = computed(() => route.params?.category ?? 'discover')
/**
* The H1 to be used on the website
*/
const pageHeading = computed(() => {
if (currentCategory.value in APPS_SECTION_ENUM) {
return APPS_SECTION_ENUM[currentCategory.value]
}
const category = store.getCategoryById(currentCategory.value)
return category?.displayName ?? t('settings', 'Apps')
})
watch([pageHeading], () => {
window.document.title = `${pageHeading.value} - Apps - Nextcloud`
const appStoreLabel = t('settings', 'App Store')
const viewLabel = computed(() => APPS_SECTION_ENUM[currentCategory.value] ?? store.getCategoryById(currentCategory.value)?.displayName ?? appStoreLabel)
watchEffect(() => {
window.document.title = `${viewLabel.value} - ${appStoreLabel} - Nextcloud`
})
// TODO this part should be migrated to pinia
@ -87,4 +85,12 @@ onBeforeMount(() => {
.empty-content__loading {
height: 100%;
}
.app-settings-content__label {
margin-block-start: var(--app-navigation-padding);
margin-inline-start: calc(var(--default-clickable-area) + var(--app-navigation-padding) * 2);
min-height: var(--default-clickable-area);
line-height: var(--default-clickable-area);
vertical-align: center;
}
</style>

@ -2,9 +2,17 @@
<!-- Categories & filters -->
<NcAppNavigation :aria-label="t('settings', 'Apps')">
<template #list>
<NcAppNavigationItem id="app-category-your-apps"
<NcAppNavigationItem id="app-category-discover"
:to="{ name: 'apps' }"
:exact="true"
:name="APPS_SECTION_ENUM.discover">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.discover" />
</template>
</NcAppNavigationItem>
<NcAppNavigationItem id="app-category-installed"
:to="{ name: 'apps-category', params: { category: 'installed'} }"
:exact="true"
:name="APPS_SECTION_ENUM.installed">
<template #icon>
<NcIconSvgWrapper :path="APPSTORE_CATEGORY_ICONS.installed" />

@ -35,7 +35,7 @@ describe('Settings: App management', { testIsolation: true }, () => {
// I am logged in as the admin
cy.login(admin)
// I open the Apps management
cy.visit('/settings/apps')
cy.visit('/settings/apps/installed')
})
it('Can enable an installed app', () => {

Loading…
Cancel
Save