mirror of
https://github.com/nextcloud/server.git
synced 2026-06-09 00:32:29 -04:00
feat(settings): Implement new app discover section for app management
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
parent
072393d017
commit
4cadb82850
7 changed files with 214 additions and 20 deletions
|
|
@ -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>
|
||||
81
apps/settings/src/components/AppStoreDiscover/PostType.vue
Normal file
81
apps/settings/src/components/AppStoreDiscover/PostType.vue
Normal file
|
|
@ -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…
Reference in a new issue