aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorFerdinand Thiessen <opensource@fthiessen.de>2023-09-25 14:21:23 +0200
committerFerdinand Thiessen <opensource@fthiessen.de>2023-10-20 00:24:17 +0200
commite9d4036389097708a6075d8882c32b1c7db4fb0f (patch)
tree97216c9a992ca14660193d5b0926fc72997bd044
parent363d9ebb130862d5fc5617e94b1c369caf02553f (diff)
downloadnextcloud-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.php5
-rw-r--r--apps/theming/lib/Controller/ThemingController.php43
-rw-r--r--apps/theming/lib/Listener/BeforePreferenceListener.php38
-rw-r--r--apps/theming/lib/Settings/Admin.php35
-rw-r--r--apps/theming/lib/Settings/Personal.php30
-rw-r--r--apps/theming/src/AdminTheming.vue7
-rw-r--r--apps/theming/src/UserThemes.vue6
-rw-r--r--apps/theming/src/components/AppOrderSelector.vue130
-rw-r--r--apps/theming/src/components/AppOrderSelectorElement.vue145
-rw-r--r--apps/theming/src/components/UserAppMenuSection.vue122
-rw-r--r--apps/theming/src/components/admin/AppMenuSection.vue120
-rw-r--r--apps/theming/tests/Settings/PersonalTest.php13
-rw-r--r--custom.d.ts2
-rw-r--r--cypress/e2e/theming/navigation-bar-settings.cy.ts212
-rw-r--r--package-lock.json129
-rw-r--r--package.json1
-rw-r--r--tests/lib/App/AppManagerTest.php58
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));
}
}