* 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>tags/v28.0.0beta1
*/ | */ | ||||
return [ | return [ | ||||
'routes' => [ | 'routes' => [ | ||||
[ | |||||
'name' => 'Theming#updateAppMenu', | |||||
'url' => '/ajax/updateAppMenu', | |||||
'verb' => 'PUT', | |||||
], | |||||
[ | [ | ||||
'name' => 'Theming#updateStylesheet', | 'name' => 'Theming#updateStylesheet', | ||||
'url' => '/ajax/updateStylesheet', | 'url' => '/ajax/updateStylesheet', |
*/ | */ | ||||
namespace OCA\Theming\Controller; | namespace OCA\Theming\Controller; | ||||
use InvalidArgumentException; | |||||
use OCA\Theming\ImageManager; | use OCA\Theming\ImageManager; | ||||
use OCA\Theming\Service\ThemesService; | use OCA\Theming\Service\ThemesService; | ||||
use OCA\Theming\ThemingDefaults; | use OCA\Theming\ThemingDefaults; | ||||
]); | ]); | ||||
} | } | ||||
/** | |||||
* @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 | * Check that a string is a valid http/https url | ||||
*/ | */ | ||||
*/ | */ | ||||
public function undoAll(): DataResponse { | public function undoAll(): DataResponse { | ||||
$this->themingDefaults->undoAll(); | $this->themingDefaults->undoAll(); | ||||
$this->appManager->setDefaultApps([]); | |||||
return new DataResponse( | return new DataResponse( | ||||
[ | [ |
namespace OCA\Theming\Listener; | namespace OCA\Theming\Listener; | ||||
use OCA\Theming\AppInfo\Application; | use OCA\Theming\AppInfo\Application; | ||||
use OCP\App\IAppManager; | |||||
use OCP\Config\BeforePreferenceDeletedEvent; | use OCP\Config\BeforePreferenceDeletedEvent; | ||||
use OCP\Config\BeforePreferenceSetEvent; | use OCP\Config\BeforePreferenceSetEvent; | ||||
use OCP\EventDispatcher\Event; | use OCP\EventDispatcher\Event; | ||||
use OCP\EventDispatcher\IEventListener; | use OCP\EventDispatcher\IEventListener; | ||||
class BeforePreferenceListener implements IEventListener { | class BeforePreferenceListener implements IEventListener { | ||||
public function __construct( | |||||
private IAppManager $appManager, | |||||
) { | |||||
} | |||||
public function handle(Event $event): void { | public function handle(Event $event): void { | ||||
if (!$event instanceof BeforePreferenceSetEvent | if (!$event instanceof BeforePreferenceSetEvent | ||||
&& !$event instanceof BeforePreferenceDeletedEvent) { | && !$event instanceof BeforePreferenceDeletedEvent) { | ||||
// Invalid event type | |||||
return; | 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') { | if ($event->getConfigKey() !== 'shortcuts_disabled') { | ||||
// Not allowed config key | |||||
return; | return; | ||||
} | } | ||||
$event->setValid(true); | $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); | |||||
} | |||||
} | } |
use OCP\Util; | use OCP\Util; | ||||
class Admin implements IDelegatedSettings { | 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, | |||||
) { | |||||
} | } | ||||
/** | /** | ||||
$carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key); | $carry[$key] = $this->imageManager->getSupportedUploadImageFormats($key); | ||||
return $carry; | return $carry; | ||||
}, []); | }, []); | ||||
$this->initialState->provideInitialState('adminThemingParameters', [ | $this->initialState->provideInitialState('adminThemingParameters', [ | ||||
'isThemable' => $themable, | 'isThemable' => $themable, | ||||
'notThemableErrorMessage' => $errorMessage, | 'notThemableErrorMessage' => $errorMessage, | ||||
'slogan' => $this->themingDefaults->getSlogan(), | 'slogan' => $this->themingDefaults->getSlogan(), | ||||
'color' => $this->themingDefaults->getDefaultColorPrimary(), | 'color' => $this->themingDefaults->getDefaultColorPrimary(), | ||||
'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''), | 'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''), | ||||
'allowedMimeTypes' => $allowedMimeTypes, | |||||
'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''), | 'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''), | ||||
'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''), | 'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''), | ||||
'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''), | 'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''), | ||||
'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), | 'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'), | ||||
'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), | 'canThemeIcons' => $this->imageManager->shouldReplaceIcons(), | ||||
'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), | 'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(), | ||||
'allowedMimeTypes' => $allowedMimeTypes, | |||||
'defaultApps' => array_filter(explode(',', $this->config->getSystemValueString('defaultapp', ''))), | |||||
]); | ]); | ||||
Util::addScript($this->appName, 'admin-theming'); | Util::addScript($this->appName, 'admin-theming'); |
use OCA\Theming\ITheme; | use OCA\Theming\ITheme; | ||||
use OCA\Theming\Service\ThemesService; | use OCA\Theming\Service\ThemesService; | ||||
use OCA\Theming\ThemingDefaults; | use OCA\Theming\ThemingDefaults; | ||||
use OCP\App\IAppManager; | |||||
use OCP\AppFramework\Http\TemplateResponse; | use OCP\AppFramework\Http\TemplateResponse; | ||||
use OCP\AppFramework\Services\IInitialState; | use OCP\AppFramework\Services\IInitialState; | ||||
use OCP\IConfig; | use OCP\IConfig; | ||||
class Personal implements ISettings { | 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 { | public function getForm(): TemplateResponse { | ||||
}); | }); | ||||
} | } | ||||
// Get the default app enforced by admin | |||||
$forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false); | |||||
$this->initialStateService->provideInitialState('themes', array_values($themes)); | $this->initialStateService->provideInitialState('themes', array_values($themes)); | ||||
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme); | $this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme); | ||||
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled()); | $this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled()); | ||||
$this->initialStateService->provideInitialState('enforcedDefaultApp', $forcedDefaultApp); | |||||
Util::addScript($this->appName, 'personal-theming'); | Util::addScript($this->appName, 'personal-theming'); | ||||
</a> | </a> | ||||
</div> | </div> | ||||
</NcSettingsSection> | </NcSettingsSection> | ||||
<AppMenuSection :default-apps.sync="defaultApps" /> | |||||
</section> | </section> | ||||
</template> | </template> | ||||
import ColorPickerField from './components/admin/ColorPickerField.vue' | import ColorPickerField from './components/admin/ColorPickerField.vue' | ||||
import FileInputField from './components/admin/FileInputField.vue' | import FileInputField from './components/admin/FileInputField.vue' | ||||
import TextField from './components/admin/TextField.vue' | import TextField from './components/admin/TextField.vue' | ||||
import AppMenuSection from './components/admin/AppMenuSection.vue' | |||||
const { | const { | ||||
backgroundMime, | backgroundMime, | ||||
slogan, | slogan, | ||||
url, | url, | ||||
userThemingDisabled, | userThemingDisabled, | ||||
defaultApps, | |||||
} = loadState('theming', 'adminThemingParameters') | } = loadState('theming', 'adminThemingParameters') | ||||
const textFields = [ | const textFields = [ | ||||
name: 'AdminTheming', | name: 'AdminTheming', | ||||
components: { | components: { | ||||
AppMenuSection, | |||||
CheckboxField, | CheckboxField, | ||||
ColorPickerField, | ColorPickerField, | ||||
FileInputField, | FileInputField, | ||||
'update:theming', | 'update:theming', | ||||
], | ], | ||||
textFields, | |||||
data() { | data() { | ||||
return { | return { | ||||
textFields, | textFields, | ||||
advancedTextFields, | advancedTextFields, | ||||
advancedFileInputFields, | advancedFileInputFields, | ||||
userThemingField, | userThemingField, | ||||
defaultApps, | |||||
canThemeIcons, | canThemeIcons, | ||||
docUrl, | docUrl, |
{{ t('theming', 'Disable all keyboard shortcuts') }} | {{ t('theming', 'Disable all keyboard shortcuts') }} | ||||
</NcCheckboxRadioSwitch> | </NcCheckboxRadioSwitch> | ||||
</NcSettingsSection> | </NcSettingsSection> | ||||
<UserAppMenuSection /> | |||||
</section> | </section> | ||||
</template> | </template> | ||||
import BackgroundSettings from './components/BackgroundSettings.vue' | import BackgroundSettings from './components/BackgroundSettings.vue' | ||||
import ItemPreview from './components/ItemPreview.vue' | import ItemPreview from './components/ItemPreview.vue' | ||||
import UserAppMenuSection from './components/UserAppMenuSection.vue' | |||||
const availableThemes = loadState('theming', 'themes', []) | const availableThemes = loadState('theming', 'themes', []) | ||||
const enforceTheme = loadState('theming', 'enforceTheme', '') | const enforceTheme = loadState('theming', 'enforceTheme', '') | ||||
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled') | const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled') | ||||
console.debug('Available themes', availableThemes) | |||||
export default { | export default { | ||||
name: 'UserThemes', | name: 'UserThemes', | ||||
NcCheckboxRadioSwitch, | NcCheckboxRadioSwitch, | ||||
NcSettingsSection, | NcSettingsSection, | ||||
BackgroundSettings, | BackgroundSettings, | ||||
UserAppMenuSection, | |||||
}, | }, | ||||
data() { | data() { |
<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> |
<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> |
<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> |
<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> |
private ThemesService $themesService; | private ThemesService $themesService; | ||||
private IInitialState $initialStateService; | private IInitialState $initialStateService; | ||||
private ThemingDefaults $themingDefaults; | private ThemingDefaults $themingDefaults; | ||||
private IAppManager $appManager; | |||||
private Personal $admin; | private Personal $admin; | ||||
/** @var ITheme[] */ | /** @var ITheme[] */ | ||||
$this->themesService = $this->createMock(ThemesService::class); | $this->themesService = $this->createMock(ThemesService::class); | ||||
$this->initialStateService = $this->createMock(IInitialState::class); | $this->initialStateService = $this->createMock(IInitialState::class); | ||||
$this->themingDefaults = $this->createMock(ThemingDefaults::class); | $this->themingDefaults = $this->createMock(ThemingDefaults::class); | ||||
$this->appManager = $this->createMock(IAppManager::class); | |||||
$this->initThemes(); | $this->initThemes(); | ||||
$this->admin = new Personal( | $this->admin = new Personal( | ||||
Application::APP_ID, | Application::APP_ID, | ||||
'admin', | |||||
$this->config, | $this->config, | ||||
$this->themesService, | $this->themesService, | ||||
$this->initialStateService, | $this->initialStateService, | ||||
$this->themingDefaults, | $this->themingDefaults, | ||||
$this->appManager, | |||||
); | ); | ||||
} | } | ||||
->with('enforce_theme', '') | ->with('enforce_theme', '') | ||||
->willReturn($enforcedTheme); | ->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') | ->method('provideInitialState') | ||||
->withConsecutive( | ->withConsecutive( | ||||
['themes', $themesState], | ['themes', $themesState], | ||||
['enforceTheme', $enforcedTheme], | ['enforceTheme', $enforcedTheme], | ||||
['isUserThemingDisabled', false] | |||||
['isUserThemingDisabled', false], | |||||
['enforcedDefaultApp', 'forcedapp'], | |||||
); | ); | ||||
$expected = new TemplateResponse('theming', 'settings-personal'); | $expected = new TemplateResponse('theming', 'settings-personal'); |
* | * | ||||
*/ | */ | ||||
declare module '*.svg?raw' { | declare module '*.svg?raw' { | ||||
const content: any | |||||
const content: string | |||||
export default content | export default content | ||||
} | } | ||||
/** | |||||
* @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') | |||||
}) | |||||
}) | |||||
}) |
"@nextcloud/vue": "^8.0.0-beta.8", | "@nextcloud/vue": "^8.0.0-beta.8", | ||||
"@skjnldsv/sanitize-svg": "^1.0.2", | "@skjnldsv/sanitize-svg": "^1.0.2", | ||||
"@vueuse/components": "^10.4.1", | "@vueuse/components": "^10.4.1", | ||||
"@vueuse/integrations": "^10.4.1", | |||||
"autosize": "^6.0.1", | "autosize": "^6.0.1", | ||||
"backbone": "^1.4.1", | "backbone": "^1.4.1", | ||||
"blueimp-md5": "^2.19.0", | "blueimp-md5": "^2.19.0", | ||||
} | } | ||||
} | } | ||||
}, | }, | ||||
"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": { | "node_modules/@vueuse/metadata": { | ||||
"version": "10.4.1", | "version": "10.4.1", | ||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.4.1.tgz", | "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.4.1.tgz", |
"@nextcloud/vue": "^8.0.0-beta.8", | "@nextcloud/vue": "^8.0.0-beta.8", | ||||
"@skjnldsv/sanitize-svg": "^1.0.2", | "@skjnldsv/sanitize-svg": "^1.0.2", | ||||
"@vueuse/components": "^10.4.1", | "@vueuse/components": "^10.4.1", | ||||
"@vueuse/integrations": "^10.4.1", | |||||
"autosize": "^6.0.1", | "autosize": "^6.0.1", | ||||
"backbone": "^1.4.1", | "backbone": "^1.4.1", | ||||
"blueimp-md5": "^2.19.0", | "blueimp-md5": "^2.19.0", |
'', | '', | ||||
'', | '', | ||||
'{}', | '{}', | ||||
true, | |||||
'files', | 'files', | ||||
], | ], | ||||
// none specified, without fallback | |||||
[ | |||||
'', | |||||
'', | |||||
'{}', | |||||
false, | |||||
'', | |||||
], | |||||
// unexisting or inaccessible app specified, default to files | // unexisting or inaccessible app specified, default to files | ||||
[ | [ | ||||
'unexist', | 'unexist', | ||||
'', | '', | ||||
'{}', | '{}', | ||||
true, | |||||
'files', | 'files', | ||||
], | ], | ||||
// unexisting or inaccessible app specified, without fallbacks | |||||
[ | |||||
'unexist', | |||||
'', | |||||
'{}', | |||||
false, | |||||
'', | |||||
], | |||||
// non-standard app | // non-standard app | ||||
[ | [ | ||||
'settings', | 'settings', | ||||
'', | '', | ||||
'{}', | '{}', | ||||
true, | |||||
'settings', | |||||
], | |||||
// non-standard app, without fallback | |||||
[ | |||||
'settings', | |||||
'', | |||||
'{}', | |||||
false, | |||||
'settings', | 'settings', | ||||
], | ], | ||||
// non-standard app with fallback | // non-standard app with fallback | ||||
'unexist,settings', | 'unexist,settings', | ||||
'', | '', | ||||
'{}', | '{}', | ||||
true, | |||||
'settings', | 'settings', | ||||
], | ], | ||||
// user-customized defaultapp | // 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', | 'unexist,settings', | ||||
'files', | 'files', | ||||
'{"settings":[1],"files":[2]}', | '{"settings":[1],"files":[2]}', | ||||
true, | |||||
'files', | 'files', | ||||
], | ], | ||||
// user-customized apporder fallback | // user-customized apporder fallback | ||||
'', | '', | ||||
'', | '', | ||||
'{"settings":[1],"files":[2]}', | '{"settings":[1],"files":[2]}', | ||||
true, | |||||
'settings', | 'settings', | ||||
], | ], | ||||
// user-customized apporder, but called without fallback | |||||
[ | |||||
'', | |||||
'', | |||||
'{"settings":[1],"files":[2]}', | |||||
false, | |||||
'', | |||||
], | |||||
]; | ]; | ||||
} | } | ||||
/** | /** | ||||
* @dataProvider provideDefaultApps | * @dataProvider provideDefaultApps | ||||
*/ | */ | ||||
public function testGetDefaultAppForUser($defaultApps, $userDefaultApps, $userApporder, $expectedApp) { | |||||
public function testGetDefaultAppForUser($defaultApps, $userDefaultApps, $userApporder, $withFallbacks, $expectedApp) { | |||||
$user = $this->newUser('user1'); | $user = $this->newUser('user1'); | ||||
$this->userSession->expects($this->once()) | $this->userSession->expects($this->once()) | ||||
['user1', 'core', 'apporder', '[]', $userApporder], | ['user1', 'core', 'apporder', '[]', $userApporder], | ||||
]); | ]); | ||||
$this->assertEquals($expectedApp, $this->manager->getDefaultAppForUser()); | |||||
$this->assertEquals($expectedApp, $this->manager->getDefaultAppForUser(null, $withFallbacks)); | |||||
} | } | ||||
} | } |