Skip to content
Draft
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
35 changes: 34 additions & 1 deletion apps/theming/lib/Listener/BeforePreferenceListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ class BeforePreferenceListener implements IEventListener {
*/
private const ALLOWED_KEYS = ['force_enable_blur_filter', 'shortcuts_disabled', 'primary_color'];

/**
* @var string[]
*/
private const ALLOWED_CORE_KEYS = ['apporder', 'apps_pinned'];

public function __construct(
private IAppManager $appManager,
) {
Expand Down Expand Up @@ -72,7 +77,7 @@ private function handleThemingValues(BeforePreferenceSetEvent|BeforePreferenceDe
}

private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDeletedEvent $event): void {
if ($event->getConfigKey() !== 'apporder') {
if (!in_array($event->getConfigKey(), self::ALLOWED_CORE_KEYS, true)) {
// Not allowed config key
return;
}
Expand All @@ -82,6 +87,17 @@ private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDelet
return;
}

switch ($event->getConfigKey()) {
case 'apporder':
$this->validateAppOrder($event);
break;
case 'apps_pinned':
$this->validatePinnedApps($event);
break;
}
}

private function validateAppOrder(BeforePreferenceSetEvent $event): void {
$value = json_decode($event->getConfigValue(), true, flags:JSON_THROW_ON_ERROR);
if (!is_array(($value))) {
// Must be an array
Expand All @@ -97,4 +113,21 @@ private function handleCoreValues(BeforePreferenceSetEvent|BeforePreferenceDelet
}
$event->setValid(true);
}

private function validatePinnedApps(BeforePreferenceSetEvent $event): void {
$value = json_decode($event->getConfigValue(), true);
// required format: list of navigation entry ids [ id: string ]
if (!is_array($value) || $value !== array_values($value)) {
// Must be a list
return;
}

foreach ($value as $id) {
if (!is_string($id)) {
// Invalid config value, refuse the change
return;
}
}
$event->setValid(true);
}
}
1 change: 1 addition & 0 deletions apps/theming/lib/Settings/Personal.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
$this->initialStateService->provideInitialState('enableBlurFilter', $this->config->getUserValue($this->userId, 'theming', 'force_enable_blur_filter', ''));
$this->initialStateService->provideInitialState('navigationBar', [
'userAppOrder' => json_decode($this->config->getUserValue($this->userId, 'core', 'apporder', '[]'), true, flags:JSON_THROW_ON_ERROR),
'userPinnedApps' => json_decode($this->config->getUserValue($this->userId, 'core', 'apps_pinned', '[]'), true, flags:JSON_THROW_ON_ERROR),

Check failure on line 84 in apps/theming/lib/Settings/Personal.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

DeprecatedMethod

apps/theming/lib/Settings/Personal.php:84:51: DeprecatedMethod: The method OCP\IConfig::getUserValue has been marked as deprecated (see https://psalm.dev/001)
'enforcedDefaultApp' => $forcedDefaultEntry
]);

Expand Down
11 changes: 11 additions & 0 deletions apps/theming/src/components/AppOrderSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ defineProps<{
* Details like status information that need to be forwarded to the interactive elements
*/
ariaDetails: string

/**
* Show the toggle for pinning apps to the navigation bar
*/
showPin?: boolean
}>()

defineEmits<{
'toggle:pinned': [app: IApp]
}>()

/**
Expand Down Expand Up @@ -153,6 +162,8 @@ function updateStatusInfo(index: number) {
:aria-describedby="statusInfoId"
:isFirst="index === 0 || !!appList[index - 1]!.default"
:isLast="index === appList.length - 1"
:showPin="showPin"
@toggle:pinned="$emit('toggle:pinned', app)"
v-on="app.default
? {}
: {
Expand Down
21 changes: 21 additions & 0 deletions apps/theming/src/components/AppOrderSelectorElement.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import { nextTick, useTemplateRef } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
import IconPin from 'vue-material-design-icons/Pin.vue'
import IconPinOutline from 'vue-material-design-icons/PinOutline.vue'

export interface IApp {
id: string // app id
icon: string // path to the icon svg
label?: string // display name
default?: boolean // for app as default app
pinned?: boolean // for app pinned to the navigation bar
}

const props = defineProps<{
Expand All @@ -40,11 +43,16 @@ const props = defineProps<{
* Is this the last element in the list
*/
isLast?: boolean
/**
* Show the toggle for pinning the app to the navigation bar
*/
showPin?: boolean
}>()

const emit = defineEmits<{
'move:up': []
'move:down': []
'toggle:pinned': []
/**
* We need this as Sortable.js removes all native focus event listeners
*/
Expand Down Expand Up @@ -124,6 +132,19 @@ function keepFocus() {
</div>

<div class="order-selector-element__actions">
<NcButton
v-if="showPin"
:aria-label="app.pinned ? t('theming', 'Unpin app from the navigation bar') : t('theming', 'Pin app to the navigation bar')"
:aria-describedby="ariaDescribedby"
:aria-details="ariaDetails"
:pressed="!!app.pinned"
variant="tertiary-no-background"
@update:pressed="$emit('toggle:pinned')">
<template #icon>
<IconPin v-if="app.pinned" :size="20" />
<IconPinOutline v-else :size="20" />
</template>
</NcButton>
<NcButton
v-show="!isFirst && !app.default"
ref="buttonUp"
Expand Down
50 changes: 44 additions & 6 deletions apps/theming/src/components/UserSectionAppMenu.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ interface IOCSResponse<T> {
const {
/** The app order currently defined by the user */
userAppOrder,
/** The apps the user pinned to the navigation bar (if any) */
userPinnedApps,
/** The enforced default app set by the administrator (if any) */
enforcedDefaultApp,
} = loadState<{ userAppOrder: IAppOrder, enforcedDefaultApp: string }>('theming', 'navigationBar')
} = loadState<{ userAppOrder: IAppOrder, userPinnedApps: string[], 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 = loadState<INavigationEntry[]>('core', 'apps')
.filter(({ type }) => type === 'link')
.map((app) => ({ ...app, label: app.name, default: app.default && app.id === enforcedDefaultApp }))
.map((app) => ({ ...app, label: app.name, default: app.default && app.id === enforcedDefaultApp, pinned: userPinnedApps.includes(app.id) }))

/**
* The current apporder (sorted by user)
Expand All @@ -60,17 +62,25 @@ const hasCustomAppOrder = ref(!Array.isArray(userAppOrder) || Object.values(user
*/
const hasAppOrderChanged = computed(() => initialAppOrder.some(({ id }, index) => id !== appOrder.value[index]?.id))

/**
* Track if the pinned apps have changed, so the user can be informed to reload
*/
const hasPinnedAppsChanged = computed(() => initialAppOrder.some(({ id, pinned }) => pinned !== appOrder.value.find((app) => app.id === id)?.pinned))

/** ID of the "app order has changed" NcNodeCard, used for the aria-details of the apporder */
const elementIdAppOrderChanged = 'theming-apporder-changed-infocard'

/** ID of the "pinned apps have changed" NcNodeCard, used for the aria-details of the apporder */
const elementIdPinnedAppsChanged = 'theming-pinnedapps-changed-infocard'

/** 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'

/**
* The aria-details value of the app order selector
* contains the space separated list of element ids of NcNoteCards
*/
const ariaDetailsAppOrder = computed(() => (hasAppOrderChanged.value ? `${elementIdAppOrderChanged} ` : '') + (enforcedDefaultApp ? elementIdEnforcedDefaultApp : ''))
const ariaDetailsAppOrder = computed(() => (hasAppOrderChanged.value ? `${elementIdAppOrderChanged} ` : '') + (hasPinnedAppsChanged.value ? `${elementIdPinnedAppsChanged} ` : '') + (enforcedDefaultApp ? elementIdEnforcedDefaultApp : ''))

/**
* Update the app order, called when the user sorts entries
Expand All @@ -93,6 +103,25 @@ async function updateAppOrder(value: IApp[]) {
}
}

/**
* Update the pinned apps, called when the user toggles the pin of an entry
*
* @param app The app to toggle the pinned state of
*/
async function togglePinned(app: IApp) {
const pinned = appOrder.value
.filter(({ id, pinned }) => (id === app.id ? !pinned : pinned))
.map(({ id }) => id)

try {
await saveSetting('apps_pinned', pinned)
appOrder.value = appOrder.value.map((entry) => (entry.id === app.id ? { ...entry, pinned: !entry.pinned } : entry))
} catch (error) {
logger.error('Could not update the pinned apps', { error })
showError(t('theming', 'Could not update the pinned apps'))
}
}

/**
* Reset the app order to the default
*/
Expand All @@ -101,13 +130,14 @@ async function resetAppOrder() {
await saveSetting('apporder', [])
hasCustomAppOrder.value = false

// Reset our app order list
// Reset our app order list, keeping the pinned state of the entries
const pinnedIds = new Set(appOrder.value.filter(({ pinned }) => pinned).map(({ id }) => id))
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 }))
appOrder.value = data.ocs.data.map((app) => ({ ...app, label: app.name, default: app.default && app.app === enforcedDefaultApp, pinned: pinnedIds.has(app.id) }))
} catch (error) {
logger.error('Could not reset the app order', { error })
showError(t('theming', 'Could not reset the app order'))
Expand All @@ -134,18 +164,26 @@ async function saveSetting(key: string, value: unknown) {
<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>
<p>
{{ t('theming', 'Pinned apps are shown directly in the navigation bar, while all other apps stay available in the apps menu.') }}
</p>
<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" :id="elementIdAppOrderChanged" type="info">
{{ t('theming', 'The app order was changed, to see it in action you have to reload the page.') }}
</NcNoteCard>
<NcNoteCard v-if="hasPinnedAppsChanged" :id="elementIdPinnedAppsChanged" type="info">
{{ t('theming', 'The pinned apps were changed, to see them in the navigation bar you have to reload the page.') }}
</NcNoteCard>

<AppOrderSelector
:class="$style.userSectionAppMenu__selector"
:aria-details="ariaDetailsAppOrder"
:modelValue="appOrder"
@update:modelValue="updateAppOrder" />
showPin
@update:modelValue="updateAppOrder"
@toggle:pinned="togglePinned" />

<NcButton
data-test-id="btn-apporder-reset"
Expand Down
Loading
Loading