Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

theming: Allow to reset custom app order and keep focus when reordering #41024

Merged
merged 3 commits into from
Oct 22, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading