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 | |
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>
-rw-r--r-- | apps/theming/appinfo/routes.php | 5 | ||||
-rw-r--r-- | apps/theming/lib/Controller/ThemingController.php | 43 | ||||
-rw-r--r-- | apps/theming/lib/Listener/BeforePreferenceListener.php | 38 | ||||
-rw-r--r-- | apps/theming/lib/Settings/Admin.php | 35 | ||||
-rw-r--r-- | apps/theming/lib/Settings/Personal.php | 30 | ||||
-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 | ||||
-rw-r--r-- | apps/theming/tests/Settings/PersonalTest.php | 13 | ||||
-rw-r--r-- | custom.d.ts | 2 | ||||
-rw-r--r-- | cypress/e2e/theming/navigation-bar-settings.cy.ts | 212 | ||||
-rw-r--r-- | package-lock.json | 129 | ||||
-rw-r--r-- | package.json | 1 | ||||
-rw-r--r-- | tests/lib/App/AppManagerTest.php | 58 |
17 files changed, 1048 insertions, 48 deletions
diff --git a/apps/theming/appinfo/routes.php b/apps/theming/appinfo/routes.php index 8647ae135a8..0d0eacff076 100644 --- a/apps/theming/appinfo/routes.php +++ b/apps/theming/appinfo/routes.php @@ -30,6 +30,11 @@ return [ 'routes' => [ [ + 'name' => 'Theming#updateAppMenu', + 'url' => '/ajax/updateAppMenu', + 'verb' => 'PUT', + ], + [ 'name' => 'Theming#updateStylesheet', 'url' => '/ajax/updateStylesheet', 'verb' => 'POST' diff --git a/apps/theming/lib/Controller/ThemingController.php b/apps/theming/lib/Controller/ThemingController.php index 1d6d5100a46..e8f6ec6289b 100644 --- a/apps/theming/lib/Controller/ThemingController.php +++ b/apps/theming/lib/Controller/ThemingController.php @@ -38,6 +38,7 @@ */ namespace OCA\Theming\Controller; +use InvalidArgumentException; use OCA\Theming\ImageManager; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; @@ -181,6 +182,47 @@ class ThemingController extends Controller { } /** + * @AuthorizedAdminSetting(settings=OCA\Theming\Settings\Admin) + * @param string $setting + * @param mixed $value + * @return DataResponse + * @throws NotPermittedException + */ + public function updateAppMenu($setting, $value) { + $error = null; + switch ($setting) { + case 'defaultApps': + if (is_array($value)) { + try { + $this->appManager->setDefaultApps($value); + } catch (InvalidArgumentException $e) { + $error = $this->l10n->t('Invalid app given'); + } + } else { + $error = $this->l10n->t('Invalid type for setting "defaultApp" given'); + } + break; + default: + $error = $this->l10n->t('Invalid setting key'); + } + if ($error !== null) { + return new DataResponse([ + 'data' => [ + 'message' => $error, + ], + 'status' => 'error' + ], Http::STATUS_BAD_REQUEST); + } + + return new DataResponse([ + 'data' => [ + 'message' => $this->l10n->t('Saved'), + ], + 'status' => 'success' + ]); + } + + /** * Check that a string is a valid http/https url */ private function isValidUrl(string $url): bool { @@ -299,6 +341,7 @@ class ThemingController extends Controller { */ public function undoAll(): DataResponse { $this->themingDefaults->undoAll(); + $this->appManager->setDefaultApps([]); return new DataResponse( [ diff --git a/apps/theming/lib/Listener/BeforePreferenceListener.php b/apps/theming/lib/Listener/BeforePreferenceListener.php index a1add86e600..3c2cdede9f9 100644 --- a/apps/theming/lib/Listener/BeforePreferenceListener.php +++ b/apps/theming/lib/Listener/BeforePreferenceListener.php @@ -26,23 +26,34 @@ declare(strict_types=1); namespace OCA\Theming\Listener; use OCA\Theming\AppInfo\Application; +use OCP\App\IAppManager; use OCP\Config\BeforePreferenceDeletedEvent; use OCP\Config\BeforePreferenceSetEvent; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; class BeforePreferenceListener implements IEventListener { + public function __construct( + private IAppManager $appManager, + ) { + } + public function handle(Event $event): void { if (!$event instanceof BeforePreferenceSetEvent && !$event instanceof BeforePreferenceDeletedEvent) { + // Invalid event type return; } - if ($event->getAppId() !== Application::APP_ID) { - return; + switch ($event->getAppId()) { + case Application::APP_ID: $this->handleThemingValues($event); break; + case 'core': $this->handleCoreValues($event); break; } + } + private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { if ($event->getConfigKey() !== 'shortcuts_disabled') { + // Not allowed config key return; } @@ -53,4 +64,27 @@ class BeforePreferenceListener implements IEventListener { $event->setValid(true); } + + private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void { + if ($event->getConfigKey() !== 'apporder') { + // Not allowed config key + return; + } + + if ($event instanceof BeforePreferenceDeletedEvent) { + $event->setValid(true); + return; + } + + $value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR); + if (is_array(($value))) { + foreach ($value as $appName => $order) { + if (!$this->appManager->isEnabledForUser($appName) || !is_array($order) || empty($order) || !is_numeric($order[key($order)])) { + // Invalid config value, refuse the change + return; + } + } + } + $event->setValid(true); + } } diff --git a/apps/theming/lib/Settings/Admin.php b/apps/theming/lib/Settings/Admin.php index ee46e62114d..9bd92a47c1f 100644 --- a/apps/theming/lib/Settings/Admin.php +++ b/apps/theming/lib/Settings/Admin.php @@ -40,28 +40,16 @@ use OCP\Settings\IDelegatedSettings; use OCP\Util; class Admin implements IDelegatedSettings { - private string $appName; - private IConfig $config; - private IL10N $l; - private ThemingDefaults $themingDefaults; - private IInitialState $initialState; - private IURLGenerator $urlGenerator; - private ImageManager $imageManager; - public function __construct(string $appName, - IConfig $config, - IL10N $l, - ThemingDefaults $themingDefaults, - IInitialState $initialState, - IURLGenerator $urlGenerator, - ImageManager $imageManager) { - $this->appName = $appName; - $this->config = $config; - $this->l = $l; - $this->themingDefaults = $themingDefaults; - $this->initialState = $initialState; - $this->urlGenerator = $urlGenerator; - $this->imageManager = $imageManager; + public function __construct( + private string $appName, + private IConfig $config, + private IL10N $l, + private ThemingDefaults $themingDefaults, + private IInitialState $initialState, + private IURLGenerator $urlGenerator, + private ImageManager $imageManager, + ) { } /** @@ -80,7 +68,7 @@ class Admin implements IDelegatedSettings { $carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key); return $carry; }, []); - + $this->initialState->provideInitialState('adminThemingParameters', [ 'isThemable' => $themable, 'notThemableErrorMessage' => $errorMessage, @@ -89,6 +77,7 @@ class Admin implements IDelegatedSettings { 'slogan' => $this->themingDefaults->getSlogan(), 'color' => $this->themingDefaults->getDefaultColorPrimary(), 'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''), + 'allowedMimeTypes' => $allowedMimeTypes, 'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''), 'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''), 'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''), @@ -98,7 +87,7 @@ class Admin implements IDelegatedSettings { 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), - 'allowedMimeTypes' => $allowedMimeTypes, + 'defaultApps' => array_filter(explode(',', $this->config->getSystemValueString('defaultapp', ''))), ]); Util::addScript($this->appName, 'admin-theming'); diff --git a/apps/theming/lib/Settings/Personal.php b/apps/theming/lib/Settings/Personal.php index 5b0dc742574..4b7a7b0e8a1 100644 --- a/apps/theming/lib/Settings/Personal.php +++ b/apps/theming/lib/Settings/Personal.php @@ -28,6 +28,7 @@ namespace OCA\Theming\Settings; use OCA\Theming\ITheme; use OCA\Theming\Service\ThemesService; use OCA\Theming\ThemingDefaults; +use OCP\App\IAppManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IConfig; @@ -36,22 +37,15 @@ use OCP\Util; class Personal implements ISettings { - protected string $appName; - private IConfig $config; - private ThemesService $themesService; - private IInitialState $initialStateService; - private ThemingDefaults $themingDefaults; - - public function __construct(string $appName, - IConfig $config, - ThemesService $themesService, - IInitialState $initialStateService, - ThemingDefaults $themingDefaults) { - $this->appName = $appName; - $this->config = $config; - $this->themesService = $themesService; - $this->initialStateService = $initialStateService; - $this->themingDefaults = $themingDefaults; + public function __construct( + protected string $appName, + private string $userId, + private IConfig $config, + private ThemesService $themesService, + private IInitialState $initialStateService, + private ThemingDefaults $themingDefaults, + private IAppManager $appManager, + ) { } public function getForm(): TemplateResponse { @@ -74,9 +68,13 @@ class Personal implements ISettings { }); } + // Get the default app enforced by admin + $forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false); + $this->initialStateService->provideInitialState('themes', array_values($themes)); $this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme); $this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled()); + $this->initialStateService->provideInitialState('enforcedDefaultApp', $forcedDefaultApp); Util::addScript($this->appName, 'personal-theming'); 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> diff --git a/apps/theming/tests/Settings/PersonalTest.php b/apps/theming/tests/Settings/PersonalTest.php index 4e9be5ef994..872cd7af29d 100644 --- a/apps/theming/tests/Settings/PersonalTest.php +++ b/apps/theming/tests/Settings/PersonalTest.php @@ -54,6 +54,7 @@ class PersonalTest extends TestCase { private ThemesService $themesService; private IInitialState $initialStateService; private ThemingDefaults $themingDefaults; + private IAppManager $appManager; private Personal $admin; /** @var ITheme[] */ @@ -65,6 +66,7 @@ class PersonalTest extends TestCase { $this->themesService = $this->createMock(ThemesService::class); $this->initialStateService = $this->createMock(IInitialState::class); $this->themingDefaults = $this->createMock(ThemingDefaults::class); + $this->appManager = $this->createMock(IAppManager::class); $this->initThemes(); @@ -75,10 +77,12 @@ class PersonalTest extends TestCase { $this->admin = new Personal( Application::APP_ID, + 'admin', $this->config, $this->themesService, $this->initialStateService, $this->themingDefaults, + $this->appManager, ); } @@ -112,12 +116,17 @@ class PersonalTest extends TestCase { ->with('enforce_theme', '') ->willReturn($enforcedTheme); - $this->initialStateService->expects($this->exactly(3)) + $this->appManager->expects($this->once()) + ->method('getDefaultAppForUser') + ->willReturn('forcedapp'); + + $this->initialStateService->expects($this->exactly(4)) ->method('provideInitialState') ->withConsecutive( ['themes', $themesState], ['enforceTheme', $enforcedTheme], - ['isUserThemingDisabled', false] + ['isUserThemingDisabled', false], + ['enforcedDefaultApp', 'forcedapp'], ); $expected = new TemplateResponse('theming', 'settings-personal'); diff --git a/custom.d.ts b/custom.d.ts index 6a7b595c981..aa25f35ecca 100644 --- a/custom.d.ts +++ b/custom.d.ts @@ -20,7 +20,7 @@ * */ declare module '*.svg?raw' { - const content: any + const content: string export default content } diff --git a/cypress/e2e/theming/navigation-bar-settings.cy.ts b/cypress/e2e/theming/navigation-bar-settings.cy.ts new file mode 100644 index 00000000000..50c48d5ac6d --- /dev/null +++ b/cypress/e2e/theming/navigation-bar-settings.cy.ts @@ -0,0 +1,212 @@ +/** + * @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de> + * + * @author Ferdinand Thiessen <opensource@fthiessen.de> + * + * @license AGPL-3.0-or-later + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +import { User } from '@nextcloud/cypress' + +const admin = new User('admin', 'admin') + +describe('Admin theming set default apps', () => { + before(function() { + // Just in case previous test failed + cy.resetAdminTheming() + cy.login(admin) + }) + + it('See the current default app is the dashboard', () => { + cy.visit('/') + cy.url().should('match', /apps\/dashboard/) + cy.get('#nextcloud').click() + cy.url().should('match', /apps\/dashboard/) + }) + + it('See the default app settings', () => { + cy.visit('/settings/admin/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-switch-default-app]').should('exist') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + }) + + it('Toggle the "use custom default app" switch', () => { + cy.get('[data-cy-switch-default-app] input').should('not.be.checked') + cy.get('[data-cy-switch-default-app] label').click() + cy.get('[data-cy-switch-default-app] input').should('be.checked') + }) + + it('See the default app order selector', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + else cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'files') + }) + }) + + it('Change the default app', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"]').scrollIntoView() + + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + + }) + + it('See the default app is changed', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'files') + else cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + }) + + cy.get('#nextcloud').click() + cy.url().should('match', /apps\/files/) + }) + + it('Toggle the "use custom default app" switch back to reset the default apps', () => { + cy.visit('/settings/admin/theming') + cy.get('[data-cy-switch-default-app]').scrollIntoView() + + cy.get('[data-cy-switch-default-app] input').should('be.checked') + cy.get('[data-cy-switch-default-app] label').click() + cy.get('[data-cy-switch-default-app] input').should('be.not.checked') + }) + + it('See the default app is changed back to default', () => { + cy.get('#nextcloud').click() + cy.url().should('match', /apps\/dashboard/) + }) +}) + +describe('User theming set app order', () => { + before(() => { + cy.resetAdminTheming() + // Create random user for this test + cy.createRandomUser().then((user) => { + cy.login(user) + }) + }) + + after(() => cy.logout()) + + it('See the app order settings', () => { + cy.visit('/settings/user/theming') + + cy.get('.settings-section').contains('Navigation bar settings').should('exist') + cy.get('[data-cy-app-order]').scrollIntoView() + }) + + it('See that the dashboard app is the first one', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element]').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + else cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'files') + }) + + cy.get('.app-menu-main .app-menu-entry').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-app-id', 'dashboard') + else cy.wrap($el).should('have.attr', 'data-app-id', 'files') + }) + }) + + it('Change the app order', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + + cy.get('[data-cy-app-order] [data-cy-app-order-element]').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'files') + else cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + }) + }) + + it('See the app menu order is changed', () => { + cy.reload() + cy.get('.app-menu-main .app-menu-entry').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-app-id', 'files') + else cy.wrap($el).should('have.attr', 'data-app-id', 'dashboard') + }) + }) +}) + +describe('User theming set app order with default app', () => { + before(() => { + cy.resetAdminTheming() + // install a third app + cy.runOccCommand('app:install --force --allow-unstable calendar') + // set calendar as default app + cy.runOccCommand('config:system:set --value "calendar,files" defaultapp') + + // Create random user for this test + cy.createRandomUser().then((user) => { + cy.login(user) + }) + }) + + after(() => { + cy.logout() + cy.runOccCommand('app:remove calendar') + }) + + it('See calendar is the default app', () => { + cy.visit('/') + cy.url().should('match', /apps\/calendar/) + + cy.get('.app-menu-main .app-menu-entry').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-app-id', 'calendar') + }) + }) + + it('See the app order settings: calendar is the first one', () => { + cy.visit('/settings/user/theming') + cy.get('[data-cy-app-order]').scrollIntoView() + cy.get('[data-cy-app-order] [data-cy-app-order-element]').should('have.length', 3).each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'calendar') + else if (idx === 1) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + }) + }) + + it('Can not change the default app', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="calendar"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="calendar"] [data-cy-app-order-button="down"]').should('not.be.visible') + + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="up"]').should('not.be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="dashboard"] [data-cy-app-order-button="down"]').should('be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="down"]').should('not.be.visible') + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('be.visible') + }) + + it('Change the other apps order', () => { + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').click() + cy.get('[data-cy-app-order] [data-cy-app-order-element="files"] [data-cy-app-order-button="up"]').should('not.be.visible') + + cy.get('[data-cy-app-order] [data-cy-app-order-element]').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'calendar') + else if (idx === 1) cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'files') + else cy.wrap($el).should('have.attr', 'data-cy-app-order-element', 'dashboard') + }) + }) + + it('See the app menu order is changed', () => { + cy.reload() + cy.get('.app-menu-main .app-menu-entry').each(($el, idx) => { + if (idx === 0) cy.wrap($el).should('have.attr', 'data-app-id', 'calendar') + else if (idx === 1) cy.wrap($el).should('have.attr', 'data-app-id', 'files') + else cy.wrap($el).should('have.attr', 'data-app-id', 'dashboard') + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index fa18e25a349..05661332f1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "@nextcloud/vue": "^8.0.0-beta.8", "@skjnldsv/sanitize-svg": "^1.0.2", "@vueuse/components": "^10.4.1", + "@vueuse/integrations": "^10.4.1", "autosize": "^6.0.1", "backbone": "^1.4.1", "blueimp-md5": "^2.19.0", @@ -6661,6 +6662,134 @@ } } }, + "node_modules/@vueuse/integrations": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-10.5.0.tgz", + "integrity": "sha512-fm5sXLCK0Ww3rRnzqnCQRmfjDURaI4xMsx+T+cec0ngQqHx/JgUtm8G0vRjwtonIeTBsH1Q8L3SucE+7K7upJQ==", + "dependencies": { + "@vueuse/core": "10.5.0", + "@vueuse/shared": "10.5.0", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "*", + "axios": "*", + "change-case": "*", + "drauu": "*", + "focus-trap": "*", + "fuse.js": "*", + "idb-keyval": "*", + "jwt-decode": "*", + "nprogress": "*", + "qrcode": "*", + "sortablejs": "*", + "universal-cookie": "*" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/integrations/node_modules/@types/web-bluetooth": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.18.tgz", + "integrity": "sha512-v/ZHEj9xh82usl8LMR3GarzFY1IrbXJw5L4QfQhokjRV91q+SelFqxQWSep1ucXEZ22+dSTwLFkXeur25sPIbw==" + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/core": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.5.0.tgz", + "integrity": "sha512-z/tI2eSvxwLRjOhDm0h/SXAjNm8N5ld6/SC/JQs6o6kpJ6Ya50LnEL8g5hoYu005i28L0zqB5L5yAl8Jl26K3A==", + "dependencies": { + "@types/web-bluetooth": "^0.0.18", + "@vueuse/metadata": "10.5.0", + "@vueuse/shared": "10.5.0", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/metadata": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.5.0.tgz", + "integrity": "sha512-fEbElR+MaIYyCkeM0SzWkdoMtOpIwO72x8WsZHRE7IggiOlILttqttM69AS13nrDxosnDBYdyy3C5mR1LCxHsw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations/node_modules/@vueuse/shared": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.5.0.tgz", + "integrity": "sha512-18iyxbbHYLst9MqU1X1QNdMHIjks6wC7XTVf0KNOv5es/Ms6gjVFCAAWTVP2JStuGqydg3DT+ExpFORUEi9yhg==", + "dependencies": { + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/integrations/node_modules/vue-demi": { + "version": "0.14.6", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", + "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, "node_modules/@vueuse/metadata": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.4.1.tgz", diff --git a/package.json b/package.json index 67f70c71c82..62746290564 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@nextcloud/vue": "^8.0.0-beta.8", "@skjnldsv/sanitize-svg": "^1.0.2", "@vueuse/components": "^10.4.1", + "@vueuse/integrations": "^10.4.1", "autosize": "^6.0.1", "backbone": "^1.4.1", "blueimp-md5": "^2.19.0", diff --git a/tests/lib/App/AppManagerTest.php b/tests/lib/App/AppManagerTest.php index 73ac7b79909..104b0941644 100644 --- a/tests/lib/App/AppManagerTest.php +++ b/tests/lib/App/AppManagerTest.php @@ -609,20 +609,47 @@ class AppManagerTest extends TestCase { '', '', '{}', + true, 'files', ], + // none specified, without fallback + [ + '', + '', + '{}', + false, + '', + ], // unexisting or inaccessible app specified, default to files [ 'unexist', '', '{}', + true, 'files', ], + // unexisting or inaccessible app specified, without fallbacks + [ + 'unexist', + '', + '{}', + false, + '', + ], // non-standard app [ 'settings', '', '{}', + true, + 'settings', + ], + // non-standard app, without fallback + [ + 'settings', + '', + '{}', + false, 'settings', ], // non-standard app with fallback @@ -630,13 +657,31 @@ class AppManagerTest extends TestCase { 'unexist,settings', '', '{}', + true, 'settings', ], // user-customized defaultapp [ + '', + 'files', + '', + true, + 'files', + ], + // user-customized defaultapp with systemwide + [ + 'unexist,settings', + 'files', + '', + true, + 'files', + ], + // user-customized defaultapp with system wide and apporder + [ 'unexist,settings', 'files', '{"settings":[1],"files":[2]}', + true, 'files', ], // user-customized apporder fallback @@ -644,15 +689,24 @@ class AppManagerTest extends TestCase { '', '', '{"settings":[1],"files":[2]}', + true, 'settings', ], + // user-customized apporder, but called without fallback + [ + '', + '', + '{"settings":[1],"files":[2]}', + false, + '', + ], ]; } /** * @dataProvider provideDefaultApps */ - public function testGetDefaultAppForUser($defaultApps, $userDefaultApps, $userApporder, $expectedApp) { + public function testGetDefaultAppForUser($defaultApps, $userDefaultApps, $userApporder, $withFallbacks, $expectedApp) { $user = $this->newUser('user1'); $this->userSession->expects($this->once()) @@ -671,6 +725,6 @@ class AppManagerTest extends TestCase { ['user1', 'core', 'apporder', '[]', $userApporder], ]); - $this->assertEquals($expectedApp, $this->manager->getDefaultAppForUser()); + $this->assertEquals($expectedApp, $this->manager->getDefaultAppForUser(null, $withFallbacks)); } } |