diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2023-09-25 14:21:23 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2023-10-20 00:24:17 +0200 |
commit | e9d4036389097708a6075d8882c32b1c7db4fb0f (patch) | |
tree | 97216c9a992ca14660193d5b0926fc72997bd044 /apps/theming/src | |
parent | 363d9ebb130862d5fc5617e94b1c369caf02553f (diff) | |
download | nextcloud-server-e9d4036389097708a6075d8882c32b1c7db4fb0f.tar.gz nextcloud-server-e9d4036389097708a6075d8882c32b1c7db4fb0f.zip |
feat(theming): Allow to configure default apps and app order in frontend settings
* Also add API for setting the value using ajax.
* Add cypress tests for app order and defaul apps
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'apps/theming/src')
-rw-r--r-- | apps/theming/src/AdminTheming.vue | 7 | ||||
-rw-r--r-- | apps/theming/src/UserThemes.vue | 6 | ||||
-rw-r--r-- | apps/theming/src/components/AppOrderSelector.vue | 130 | ||||
-rw-r--r-- | apps/theming/src/components/AppOrderSelectorElement.vue | 145 | ||||
-rw-r--r-- | apps/theming/src/components/UserAppMenuSection.vue | 122 | ||||
-rw-r--r-- | apps/theming/src/components/admin/AppMenuSection.vue | 120 |
6 files changed, 528 insertions, 2 deletions
diff --git a/apps/theming/src/AdminTheming.vue b/apps/theming/src/AdminTheming.vue index 1ced195985e..daef18ebdce 100644 --- a/apps/theming/src/AdminTheming.vue +++ b/apps/theming/src/AdminTheming.vue @@ -106,6 +106,7 @@ </a> </div> </NcSettingsSection> + <AppMenuSection :default-apps.sync="defaultApps" /> </section> </template> @@ -118,6 +119,7 @@ import CheckboxField from './components/admin/CheckboxField.vue' import ColorPickerField from './components/admin/ColorPickerField.vue' import FileInputField from './components/admin/FileInputField.vue' import TextField from './components/admin/TextField.vue' +import AppMenuSection from './components/admin/AppMenuSection.vue' const { backgroundMime, @@ -136,6 +138,7 @@ const { slogan, url, userThemingDisabled, + defaultApps, } = loadState('theming', 'adminThemingParameters') const textFields = [ @@ -247,6 +250,7 @@ export default { name: 'AdminTheming', components: { + AppMenuSection, CheckboxField, ColorPickerField, FileInputField, @@ -259,6 +263,8 @@ export default { 'update:theming', ], + textFields, + data() { return { textFields, @@ -267,6 +273,7 @@ export default { advancedTextFields, advancedFileInputFields, userThemingField, + defaultApps, canThemeIcons, docUrl, diff --git a/apps/theming/src/UserThemes.vue b/apps/theming/src/UserThemes.vue index be76f02563d..10b34efad6c 100644 --- a/apps/theming/src/UserThemes.vue +++ b/apps/theming/src/UserThemes.vue @@ -75,6 +75,8 @@ {{ t('theming', 'Disable all keyboard shortcuts') }} </NcCheckboxRadioSwitch> </NcSettingsSection> + + <UserAppMenuSection /> </section> </template> @@ -87,6 +89,7 @@ import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection. import BackgroundSettings from './components/BackgroundSettings.vue' import ItemPreview from './components/ItemPreview.vue' +import UserAppMenuSection from './components/UserAppMenuSection.vue' const availableThemes = loadState('theming', 'themes', []) const enforceTheme = loadState('theming', 'enforceTheme', '') @@ -94,8 +97,6 @@ const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false) const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled') -console.debug('Available themes', availableThemes) - export default { name: 'UserThemes', @@ -104,6 +105,7 @@ export default { NcCheckboxRadioSwitch, NcSettingsSection, BackgroundSettings, + UserAppMenuSection, }, data() { diff --git a/apps/theming/src/components/AppOrderSelector.vue b/apps/theming/src/components/AppOrderSelector.vue new file mode 100644 index 00000000000..98f2ce3f3d5 --- /dev/null +++ b/apps/theming/src/components/AppOrderSelector.vue @@ -0,0 +1,130 @@ +<template> + <ol ref="listElement" data-cy-app-order class="order-selector"> + <AppOrderSelectorElement v-for="app,index in appList" + :key="`${app.id}${renderCount}`" + :app="app" + :is-first="index === 0 || !!appList[index - 1].default" + :is-last="index === value.length - 1" + v-on="app.default ? {} : { + 'move:up': () => moveUp(index), + 'move:down': () => moveDown(index), + }" /> + </ol> +</template> + +<script lang="ts"> +import { useSortable } from '@vueuse/integrations/useSortable' +import { PropType, computed, defineComponent, ref } from 'vue' + +import AppOrderSelectorElement from './AppOrderSelectorElement.vue' + +interface IApp { + id: string // app id + icon: string // path to the icon svg + label?: string // display name + default?: boolean // force app as default app +} + +export default defineComponent({ + name: 'AppOrderSelector', + components: { + AppOrderSelectorElement, + }, + props: { + /** + * List of apps to reorder + */ + value: { + type: Array as PropType<IApp[]>, + required: true, + }, + }, + emits: { + /** + * Update the apps list on reorder + * @param value The new value of the app list + */ + 'update:value': (value: IApp[]) => Array.isArray(value), + }, + setup(props, { emit }) { + /** + * The Element that contains the app list + */ + const listElement = ref<HTMLElement | null>(null) + + /** + * The app list with setter that will ement the `update:value` event + */ + const appList = computed({ + get: () => props.value, + // Ensure the sortable.js does not mess with the default attribute + set: (list) => { + const newValue = [...list].sort((a, b) => ((b.default ? 1 : 0) - (a.default ? 1 : 0)) || list.indexOf(a) - list.indexOf(b)) + if (newValue.some(({ id }, index) => id !== props.value[index].id)) { + emit('update:value', newValue) + } else { + // forceUpdate as the DOM has changed because of a drag event, but the reactive state has not -> wrong state + renderCount.value += 1 + } + }, + }) + + /** + * Helper to force rerender the list in case of a invalid drag event + */ + const renderCount = ref(0) + + /** + * Handle drag & drop sorting + */ + useSortable(listElement, appList, { filter: '.order-selector-element--disabled' }) + + /** + * Handle element is moved up + * @param index The index of the element that is moved + */ + const moveUp = (index: number) => { + const before = index > 1 ? props.value.slice(0, index - 1) : [] + // skip if not possible, because of default default app + if (props.value[index - 1]?.default) { + return + } + + const after = [props.value[index - 1]] + if (index < props.value.length - 1) { + after.push(...props.value.slice(index + 1)) + } + emit('update:value', [...before, props.value[index], ...after]) + } + + /** + * Handle element is moved down + * @param index The index of the element that is moved + */ + const moveDown = (index: number) => { + const before = index > 0 ? props.value.slice(0, index) : [] + before.push(props.value[index + 1]) + + const after = index < (props.value.length - 2) ? props.value.slice(index + 2) : [] + emit('update:value', [...before, props.value[index], ...after]) + } + + return { + appList, + listElement, + + moveDown, + moveUp, + + renderCount, + } + }, +}) +</script> + +<style scoped lang="scss"> +.order-selector { + width: max-content; + min-width: 260px; // align with NcSelect +} +</style> diff --git a/apps/theming/src/components/AppOrderSelectorElement.vue b/apps/theming/src/components/AppOrderSelectorElement.vue new file mode 100644 index 00000000000..ee795b6272a --- /dev/null +++ b/apps/theming/src/components/AppOrderSelectorElement.vue @@ -0,0 +1,145 @@ +<template> + <li :data-cy-app-order-element="app.id" + :class="{ + 'order-selector-element': true, + 'order-selector-element--disabled': app.default + }"> + <svg width="20" + height="20" + viewBox="0 0 20 20" + role="presentation"> + <image preserveAspectRatio="xMinYMin meet" + x="0" + y="0" + width="20" + height="20" + :xlink:href="app.icon" + class="order-selector-element__icon" /> + </svg> + + <div class="order-selector-element__label"> + {{ app.label ?? app.id }} + </div> + + <div class="order-selector-element__actions"> + <NcButton v-show="!isFirst && !app.default" + :aria-label="t('settings', 'Move up')" + data-cy-app-order-button="up" + type="tertiary-no-background" + @click="$emit('move:up')"> + <template #icon> + <IconArrowUp :size="20" /> + </template> + </NcButton> + <div v-show="isFirst || !!app.default" aria-hidden="true" class="order-selector-element__placeholder" /> + <NcButton v-show="!isLast && !app.default" + :aria-label="t('settings', 'Move down')" + data-cy-app-order-button="down" + type="tertiary-no-background" + @click="$emit('move:down')"> + <template #icon> + <IconArrowDown :size="20" /> + </template> + </NcButton> + <div v-show="isLast || !!app.default" aria-hidden="true" class="order-selector-element__placeholder" /> + </div> + </li> +</template> + +<script lang="ts"> +import { translate as t } from '@nextcloud/l10n' +import { PropType, defineComponent } from 'vue' + +import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue' +import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue' +import NcButton from '@nextcloud/vue/dist/Components/NcButton.js' + +interface IApp { + id: string // app id + icon: string // path to the icon svg + label?: string // display name + default?: boolean // for app as default app +} + +export default defineComponent({ + name: 'AppOrderSelectorElement', + components: { + IconArrowDown, + IconArrowUp, + NcButton, + }, + props: { + app: { + type: Object as PropType<IApp>, + required: true, + }, + isFirst: { + type: Boolean, + default: false, + }, + isLast: { + type: Boolean, + default: false, + }, + }, + emits: { + 'move:up': () => true, + 'move:down': () => true, + }, + setup() { + return { + t, + } + }, +}) +</script> + +<style lang="scss" scoped> +.order-selector-element { + // hide default styling + list-style: none; + // Align children + display: flex; + flex-direction: row; + align-items: center; + // Spacing + gap: 12px; + padding-inline: 12px; + + &:hover { + background-color: var(--color-background-hover); + border-radius: var(--border-radius-large); + } + + &--disabled { + border-color: var(--color-text-maxcontrast); + color: var(--color-text-maxcontrast); + + .order-selector-element__icon { + opacity: 75%; + } + } + + &__actions { + flex: 0 0; + display: flex; + flex-direction: row; + gap: 6px; + } + + &__label { + flex: 1 1; + text-overflow: ellipsis; + overflow: hidden; + } + + &__placeholder { + height: 44px; + width: 44px; + } + + &__icon { + filter: var(--background-invert-if-bright); + } +} +</style> diff --git a/apps/theming/src/components/UserAppMenuSection.vue b/apps/theming/src/components/UserAppMenuSection.vue new file mode 100644 index 00000000000..babdeb184c9 --- /dev/null +++ b/apps/theming/src/components/UserAppMenuSection.vue @@ -0,0 +1,122 @@ +<template> + <NcSettingsSection :name="t('theming', 'Navigation bar settings')"> + <p> + {{ t('theming', 'You can configure the app order used for the navigation bar. The first entry will be the default app, opened after login or when clicking on the logo.') }} + </p> + <NcNoteCard v-if="!!appOrder[0]?.default" type="info"> + {{ t('theming', 'The default app can not be changed because it was configured by the administrator.') }} + </NcNoteCard> + <NcNoteCard v-if="hasAppOrderChanged" type="info"> + {{ t('theming', 'The app order was changed, to see it in action you have to reload the page.') }} + </NcNoteCard> + <AppOrderSelector class="user-app-menu-order" :value.sync="appOrder" /> + </NcSettingsSection> +</template> + +<script lang="ts"> +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateOcsUrl } from '@nextcloud/router' +import { computed, defineComponent, ref } from 'vue' + +import axios from '@nextcloud/axios' +import AppOrderSelector from './AppOrderSelector.vue' +import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js' +import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' + +/** See NavigationManager */ +interface INavigationEntry { + /** Navigation id */ + id: string + /** Order where this entry should be shown */ + order: number + /** Target of the navigation entry */ + href: string + /** The icon used for the naviation entry */ + icon: string + /** Type of the navigation entry ('link' vs 'settings') */ + type: 'link' | 'settings' + /** Localized name of the navigation entry */ + name: string + /** Whether this is the default app */ + default?: boolean + /** App that registered this navigation entry (not necessarly the same as the id) */ + app: string + /** The key used to identify this entry in the navigations entries */ + key: number +} + +export default defineComponent({ + name: 'UserAppMenuSection', + components: { + AppOrderSelector, + NcNoteCard, + NcSettingsSection, + }, + setup() { + /** + * Track if the app order has changed, so the user can be informed to reload + */ + const hasAppOrderChanged = ref(false) + + /** The enforced default app set by the administrator (if any) */ + const enforcedDefaultApp = loadState<string|null>('theming', 'enforcedDefaultApp', null) + + /** + * Array of all available apps, it is set by a core controller for the app menu, so it is always available + */ + const allApps = ref( + Object.values(loadState<Record<string, INavigationEntry>>('core', 'apps')) + .filter(({ type }) => type === 'link') + .map((app) => ({ ...app, label: app.name, default: app.default && app.app === enforcedDefaultApp })), + ) + + /** + * Wrapper around the sortedApps list with a setter for saving any changes + */ + const appOrder = computed({ + get: () => allApps.value, + set: (value) => { + const order = {} as Record<string, Record<number, number>> + value.forEach(({ app, key }, index) => { + order[app] = { ...order[app], [key]: index } + }) + + saveSetting('apporder', order) + .then(() => { + allApps.value = value + hasAppOrderChanged.value = true + }) + .catch((error) => { + console.warn('Could not set the app order', error) + showError(t('theming', 'Could not set the app order')) + }) + }, + }) + + const saveSetting = async (key: string, value: unknown) => { + const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', { + appId: 'core', + configKey: key, + }) + return await axios.post(url, { + configValue: JSON.stringify(value), + }) + } + + return { + appOrder, + hasAppOrderChanged, + + t, + } + }, +}) +</script> + +<style scoped lang="scss"> +.user-app-menu-order { + margin-block: 12px; +} +</style> diff --git a/apps/theming/src/components/admin/AppMenuSection.vue b/apps/theming/src/components/admin/AppMenuSection.vue new file mode 100644 index 00000000000..bed170504c9 --- /dev/null +++ b/apps/theming/src/components/admin/AppMenuSection.vue @@ -0,0 +1,120 @@ +<template> + <NcSettingsSection :name="t('theming', 'Navigation bar settings')"> + <h3>{{ t('theming', 'Default app') }}</h3> + <p class="info-note"> + {{ t('theming', 'The default app is the app that is e.g. opened after login or when the logo in the menu is clicked.') }} + </p> + + <NcCheckboxRadioSwitch :checked.sync="hasCustomDefaultApp" type="switch" data-cy-switch-default-app=""> + {{ t('theming', 'Use custom default app') }} + </NcCheckboxRadioSwitch> + + <template v-if="hasCustomDefaultApp"> + <h4>{{ t('theming', 'Global default app') }}</h4> + <NcSelect v-model="selectedApps" + :close-on-select="false" + :placeholder="t('theming', 'Global default apps')" + :options="allApps" + :multiple="true" /> + <h5>{{ t('theming', 'Default app priority') }}</h5> + <p class="info-note"> + {{ t('theming', 'If an app is not enabled for a user, the next app with lower priority is used.') }} + </p> + <AppOrderSelector :value.sync="selectedApps" /> + </template> + </NcSettingsSection> +</template> + +<script lang="ts"> +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { translate as t } from '@nextcloud/l10n' +import { generateUrl } from '@nextcloud/router' +import { computed, defineComponent } from 'vue' + +import axios from '@nextcloud/axios' + +import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' +import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' +import AppOrderSelector from '../AppOrderSelector.vue' + +export default defineComponent({ + name: 'AppMenuSection', + components: { + AppOrderSelector, + NcCheckboxRadioSwitch, + NcSelect, + NcSettingsSection, + }, + props: { + defaultApps: { + type: Array, + required: true, + }, + }, + emits: { + 'update:defaultApps': (value: string[]) => Array.isArray(value) && value.every((id) => typeof id === 'string'), + }, + setup(props, { emit }) { + const hasCustomDefaultApp = computed({ + get: () => props.defaultApps.length > 0, + set: (checked: boolean) => { + if (checked) { + emit('update:defaultApps', ['dashboard', 'files']) + } else { + selectedApps.value = [] + } + }, + }) + + /** + * All enabled apps which can be navigated + */ + const allApps = Object.values( + loadState<Record<string, { id: string, name?: string, icon: string }>>('core', 'apps'), + ).map(({ id, name, icon }) => ({ label: name, id, icon })) + + /** + * Currently selected app, wrapps the setter + */ + const selectedApps = computed({ + get: () => props.defaultApps.map((id) => allApps.filter(app => app.id === id)[0]), + set(value) { + saveSetting('defaultApps', value.map(app => app.id)) + .then(() => emit('update:defaultApps', value.map(app => app.id))) + .catch(() => showError(t('theming', 'Could not set global default apps'))) + }, + }) + + const saveSetting = async (key: string, value: unknown) => { + const url = generateUrl('/apps/theming/ajax/updateAppMenu') + return await axios.put(url, { + setting: key, + value, + }) + } + + return { + allApps, + selectedApps, + hasCustomDefaultApp, + + t, + } + }, +}) +</script> + +<style scoped lang="scss"> +h3, h4 { + font-weight: bold; +} +h4, h5 { + margin-block-start: 12px; +} + +.info-note { + color: var(--color-text-maxcontrast); +} +</style> |