Browse Source

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>
Ferdinand Thiessen 7 months ago
parent
commit
e9d4036389

+ 5 - 0
apps/theming/appinfo/routes.php

@@ -29,6 +29,11 @@
  */
 return [
 	'routes' => [
+		[
+			'name' => 'Theming#updateAppMenu',
+			'url' => '/ajax/updateAppMenu',
+			'verb' => 'PUT',
+		],
 		[
 			'name' => 'Theming#updateStylesheet',
 			'url' => '/ajax/updateStylesheet',

+ 43 - 0
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;
@@ -180,6 +181,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
 	 */
@@ -299,6 +341,7 @@ class ThemingController extends Controller {
 	 */
 	public function undoAll(): DataResponse {
 		$this->themingDefaults->undoAll();
+		$this->appManager->setDefaultApps([]);
 
 		return new DataResponse(
 			[

+ 36 - 2
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);
+	}
 }

+ 12 - 23
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');

+ 14 - 16
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');
 

+ 7 - 0
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,

+ 4 - 2
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() {

+ 130 - 0
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>

+ 145 - 0
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>

+ 122 - 0
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>

+ 120 - 0
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>

+ 11 - 2
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');

+ 1 - 1
custom.d.ts

@@ -20,7 +20,7 @@
  *
  */
 declare module '*.svg?raw' {
-	const content: any
+	const content: string
 	export default content
 }
 

+ 212 - 0
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')
+		})
+	})
+})

+ 129 - 0
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",

+ 1 - 0
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",

+ 56 - 2
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));
 	}
 }