Skip to content

Commit

Permalink
Merge pull request #41024 from nextcloud/fix/follow-up-app-order
Browse files Browse the repository at this point in the history
  • Loading branch information
skjnldsv authored Oct 22, 2023
2 parents 3c0b417 + 18cb827 commit 4b70f19
Show file tree
Hide file tree
Showing 14 changed files with 319 additions and 59 deletions.
5 changes: 4 additions & 1 deletion apps/theming/lib/Settings/Personal.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ public function getForm(): TemplateResponse {
$this->initialStateService->provideInitialState('themes', array_values($themes));
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme);
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled());
$this->initialStateService->provideInitialState('enforcedDefaultApp', $forcedDefaultApp);
$this->initialStateService->provideInitialState('navigationBar', [
'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR),
'enforcedDefaultApp' => $forcedDefaultApp
]);

Util::addScript($this->appName, 'personal-theming');

Expand Down
6 changes: 4 additions & 2 deletions apps/theming/src/components/AppOrderSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import { PropType, computed, defineComponent, ref } from 'vue'
import AppOrderSelectorElement from './AppOrderSelectorElement.vue'
interface IApp {
export interface IApp {
id: string // app id
icon: string // path to the icon svg
label?: string // display name
label: string // display name
default?: boolean // force app as default app
app: string
key: number
}
export default defineComponent({
Expand Down
59 changes: 55 additions & 4 deletions apps/theming/src/components/AppOrderSelectorElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,22 @@

<div class="order-selector-element__actions">
<NcButton v-show="!isFirst && !app.default"
ref="buttonUp"
:aria-label="t('settings', 'Move up')"
data-cy-app-order-button="up"
type="tertiary-no-background"
@click="$emit('move:up')">
@click="moveUp">
<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"
ref="buttonDown"
:aria-label="t('settings', 'Move down')"
data-cy-app-order-button="down"
type="tertiary-no-background"
@click="$emit('move:down')">
@click="moveDown">
<template #icon>
<IconArrowDown :size="20" />
</template>
Expand All @@ -47,8 +49,10 @@
</template>

<script lang="ts">
import type { PropType } from 'vue'
import { translate as t } from '@nextcloud/l10n'
import { PropType, defineComponent } from 'vue'
import { defineComponent, nextTick, onUpdated, ref } from 'vue'
import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
Expand Down Expand Up @@ -86,8 +90,55 @@ export default defineComponent({
'move:up': () => true,
'move:down': () => true,
},
setup() {
setup(props, { emit }) {
const buttonUp = ref()
const buttonDown = ref()
/**
* Used to decide if we need to trigger focus() an a button on update
*/
let needsFocus = 0
/**
* Handle move up, ensure focus is kept on the button
*/
const moveUp = () => {
emit('move:up')
needsFocus = 1 // request focus on buttonUp
}
/**
* Handle move down, ensure focus is kept on the button
*/
const moveDown = () => {
emit('move:down')
needsFocus = -1 // request focus on buttonDown
}
/**
* onUpdated hook is used to reset the focus on the last used button (if requested)
* If the button is now visible anymore (because this element is the first/last) then the opposite button is focussed
*/
onUpdated(() => {
if (needsFocus !== 0) {
// focus requested
if ((needsFocus === 1 || props.isLast) && !props.isFirst) {
// either requested to btn up and it is not the first, or it was requested to btn down but it is the last
nextTick(() => buttonUp.value.$el.focus())
} else {
nextTick(() => buttonDown.value.$el.focus())
}
}
needsFocus = 0
})
return {
buttonUp,
buttonDown,
moveUp,
moveDown,
t,
}
},
Expand Down
143 changes: 112 additions & 31 deletions apps/theming/src/components/UserAppMenuSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,27 @@
<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">
<NcNoteCard v-if="enforcedDefaultApp" :id="elementIdEnforcedDefaultApp" 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">
<NcNoteCard v-if="hasAppOrderChanged" :id="elementIdAppOrderChanged" 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" />

<AppOrderSelector class="user-app-menu-order"
:aria-details="ariaDetailsAppOrder"
:value="appOrder"
@update:value="updateAppOrder" />

<NcButton data-test-id="btn-apporder-reset"
:disabled="!hasCustomAppOrder"
type="tertiary"
@click="resetAppOrder">
<template #icon>
<IconUndo :size="20" />
</template>
{{ t('theming', 'Reset default app order') }}
</NcButton>
</NcSettingsSection>
</template>

Expand All @@ -21,7 +35,9 @@ import { generateOcsUrl } from '@nextcloud/router'
import { computed, defineComponent, ref } from 'vue'
import axios from '@nextcloud/axios'
import AppOrderSelector from './AppOrderSelector.vue'
import AppOrderSelector, { IApp } from './AppOrderSelector.vue'
import IconUndo from 'vue-material-design-icons/Undo.vue'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
Expand All @@ -47,53 +63,109 @@ interface INavigationEntry {
key: number
}
/** The app order user setting */
type IAppOrder = Record<string, Record<number, number>>
/** OCS responses */
interface IOCSResponse<T> {
ocs: {
meta: unknown
data: T
}
}
export default defineComponent({
name: 'UserAppMenuSection',
components: {
AppOrderSelector,
IconUndo,
NcButton,
NcNoteCard,
NcSettingsSection,
},
setup() {
const {
/** The app order currently defined by the user */
userAppOrder,
/** The enforced default app set by the administrator (if any) */
enforcedDefaultApp,
} = loadState<{ userAppOrder: IAppOrder, enforcedDefaultApp: string }>('theming', 'navigationBar')
/**
* Array of all available apps, it is set by a core controller for the app menu, so it is always available
*/
const initialAppOrder = Object.values(loadState<Record<string, INavigationEntry>>('core', 'apps'))
.filter(({ type }) => type === 'link')
.map((app) => ({ ...app, label: app.name, default: app.default && app.app === enforcedDefaultApp }))
/**
* Check if a custom app order is used or the default is shown
*/
const hasCustomAppOrder = ref(!Array.isArray(userAppOrder) || Object.values(userAppOrder).length > 0)
/**
* Track if the app order has changed, so the user can be informed to reload
*/
const hasAppOrderChanged = ref(false)
const hasAppOrderChanged = computed(() => initialAppOrder.some(({ id }, index) => id !== appOrder.value[index].id))
/** ID of the "app order has changed" NcNodeCard, used for the aria-details of the apporder */
const elementIdAppOrderChanged = 'theming-apporder-changed-infocard'
/** The enforced default app set by the administrator (if any) */
const enforcedDefaultApp = loadState<string|null>('theming', 'enforcedDefaultApp', null)
/** ID of the "you can not change the default app" NcNodeCard, used for the aria-details of the apporder */
const elementIdEnforcedDefaultApp = 'theming-apporder-changed-infocard'
/**
* Array of all available apps, it is set by a core controller for the app menu, so it is always available
* The aria-details value of the app order selector
* contains the space separated list of element ids of NcNoteCards
*/
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 })),
)
const ariaDetailsAppOrder = computed(() => (hasAppOrderChanged.value ? `${elementIdAppOrderChanged} ` : '') + (enforcedDefaultApp ? elementIdEnforcedDefaultApp : ''))
/**
* Wrapper around the sortedApps list with a setter for saving any changes
* The current apporder (sorted by user)
*/
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 }
const appOrder = ref([...initialAppOrder])
/**
* Update the app order, called when the user sorts entries
* @param value The new app order value
*/
const updateAppOrder = (value: IApp[]) => {
const order: IAppOrder = {}
value.forEach(({ app, key }, index) => {
order[app] = { ...order[app], [key]: index }
})
saveSetting('apporder', order)
.then(() => {
appOrder.value = value as never
hasCustomAppOrder.value = true
})
.catch((error) => {
console.warn('Could not set the app order', error)
showError(t('theming', 'Could not set the app order'))
})
}
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'))
})
},
})
/**
* Reset the app order to the default
*/
const resetAppOrder = async () => {
try {
await saveSetting('apporder', [])
hasCustomAppOrder.value = false
// Reset our app order list
const { data } = await axios.get<IOCSResponse<INavigationEntry[]>>(generateOcsUrl('/core/navigation/apps'), {
headers: {
'OCS-APIRequest': 'true',
},
})
appOrder.value = data.ocs.data.map((app) => ({ ...app, label: app.name, default: app.default && app.app === enforcedDefaultApp }))
} catch (error) {
console.warn(error)
showError(t('theming', 'Could not reset the app order'))
}
}
const saveSetting = async (key: string, value: unknown) => {
const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
Expand All @@ -107,7 +179,16 @@ export default defineComponent({
return {
appOrder,
updateAppOrder,
resetAppOrder,
enforcedDefaultApp,
hasAppOrderChanged,
hasCustomAppOrder,
ariaDetailsAppOrder,
elementIdAppOrderChanged,
elementIdEnforcedDefaultApp,
t,
}
Expand Down
7 changes: 6 additions & 1 deletion apps/theming/tests/Settings/PersonalTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ public function testGetForm(string $enforcedTheme, $themesState) {
->with('enforce_theme', '')
->willReturn($enforcedTheme);

$this->config->expects($this->once())
->method('getUserValue')
->with('admin', 'core', 'apporder')
->willReturn('[]');

$this->appManager->expects($this->once())
->method('getDefaultAppForUser')
->willReturn('forcedapp');
Expand All @@ -126,7 +131,7 @@ public function testGetForm(string $enforcedTheme, $themesState) {
['themes', $themesState],
['enforceTheme', $enforcedTheme],
['isUserThemingDisabled', false],
['enforcedDefaultApp', 'forcedapp'],
['navigationBar', ['userAppOrder' => [], 'enforcedDefaultApp' => 'forcedapp']],
);

$expected = new TemplateResponse('theming', 'settings-personal');
Expand Down
Loading

0 comments on commit 4b70f19

Please sign in to comment.