Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>tags/v29.0.0beta3
@@ -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', () => { |