Merge pull request #46880 from nextcloud/fix/account-menu

fix: Make account menu follow the design (use nextcloud-vue components)
pull/45091/head
Ferdinand Thiessen 2024-08-06 09:41:04 +07:00 committed by GitHub
commit f1cc819c17
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 359 additions and 402 deletions

@ -4,38 +4,42 @@
-->
<template>
<component :is="inline ? 'div' : 'li'">
<!-- User Status = Status modal toggle -->
<button v-if="!inline"
<Fragment>
<NcListItem v-if="!inline"
class="user-status-menu-item"
@click.stop="openModal">
<NcUserStatusIcon class="user-status-icon"
:status="statusType"
aria-hidden="true" />
{{ visibleMessage }}
</button>
<!-- Dashboard Status -->
<NcButton v-else
compact
:name="visibleMessage"
@click.stop="openModal">
<template #icon>
<NcUserStatusIcon class="user-status-icon"
:status="statusType"
aria-hidden="true" />
</template>
{{ visibleMessage }}
</NcButton>
</NcListItem>
<div v-else>
<!-- Dashboard Status -->
<NcButton @click.stop="openModal">
<template #icon>
<NcUserStatusIcon class="user-status-icon"
:status="statusType"
aria-hidden="true" />
</template>
{{ visibleMessage }}
</NcButton>
</div>
<!-- Status management modal -->
<SetStatusModal v-if="isModalOpen"
:inline="inline"
@close="closeModal" />
</component>
</Fragment>
</template>
<script>
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { Fragment } from 'vue-frag'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import NcUserStatusIcon from '@nextcloud/vue/dist/Components/NcUserStatusIcon.js'
import debounce from 'debounce'
@ -46,7 +50,9 @@ export default {
name: 'UserStatus',
components: {
Fragment,
NcButton,
NcListItem,
NcUserStatusIcon,
SetStatusModal: () => import(/* webpackChunkName: 'user-status-modal' */'./components/SetStatusModal.vue'),
},
@ -166,40 +172,12 @@ export default {
</script>
<style lang="scss" scoped>
.user-status-menu-item {
// Ensure the maxcontrast color is set for the background
--color-text-maxcontrast: var(--color-text-maxcontrast-background-blur, var(--color-main-text));
width: auto;
min-width: 44px;
height: 44px;
margin: 0;
border: 0;
border-radius: var(--border-radius-pill);
background-color: var(--color-main-background-blur);
font-size: inherit;
font-weight: normal;
-webkit-backdrop-filter: var(--background-blur);
backdrop-filter: var(--background-blur);
&:active,
&:hover,
&:focus-visible {
background-color: var(--color-background-hover);
}
&:focus-visible {
box-shadow: 0 0 0 4px var(--color-main-background) !important;
outline: 2px solid var(--color-main-text) !important;
}
}
.user-status-icon {
width: 16px;
height: 16px;
margin-right: 10px;
width: 20px;
height: 20px;
margin: calc((var(--default-clickable-area) - 20px) / 2); // 20px icon size
opacity: 1 !important;
background-size: 16px;
background-size: 20px;
vertical-align: middle !important;
}
</style>

@ -4,36 +4,39 @@
-->
<template>
<li :id="id"
class="menu-entry">
<a v-if="href"
:href="href"
:class="{ active }"
@click.exact="handleClick">
<NcLoadingIcon v-if="loading"
class="menu-entry__loading-icon"
:size="18" />
<img v-else :src="cachedIcon" alt="">
{{ name }}
</a>
<button v-else>
<img :src="cachedIcon" alt="">
{{ name }}
</button>
</li>
<NcListItem :id="href ? undefined : id"
:anchor-id="id"
:active="active"
class="account-menu-entry"
compact
:href="href"
:name="name"
target="_self">
<template #icon>
<img class="account-menu-entry__icon"
:class="{ 'account-menu-entry__icon--active': active }"
:src="iconSource"
alt="">
</template>
<template v-if="loading" #indicator>
<NcLoadingIcon />
</template>
</NcListItem>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
const versionHash = loadState('core', 'versionHash', '')
export default {
name: 'UserMenuEntry',
name: 'AccountMenuEntry',
components: {
NcListItem,
NcLoadingIcon,
},
@ -67,7 +70,7 @@ export default {
},
computed: {
cachedIcon() {
iconSource() {
return `${this.icon}?v=${versionHash}`
},
},
@ -81,9 +84,20 @@ export default {
</script>
<style lang="scss" scoped>
.menu-entry {
&__loading-icon {
margin-right: 8px;
.account-menu-entry {
&__icon {
height: 16px;
width: 16px;
margin: calc((var(--default-clickable-area) - 16px) / 2); // 16px icon size
filter: var(--background-invert-if-dark);
&--active {
filter: var(--primary-invert-if-dark);
}
}
:deep(.list-item-content__main) {
width: fit-content;
}
}
</style>

@ -4,38 +4,38 @@
-->
<template>
<li :id="id"
class="menu-entry">
<component :is="profileEnabled ? 'a' : 'span'"
class="menu-entry__wrapper"
:class="{
active,
'menu-entry__wrapper--link': profileEnabled,
}"
:href="profileEnabled ? href : undefined"
@click.exact="handleClick">
<span class="menu-entry__content">
<span class="menu-entry__displayname">{{ displayName }}</span>
<NcLoadingIcon v-if="loading" :size="18" />
</span>
<span v-if="profileEnabled">{{ name }}</span>
</component>
</li>
<NcListItem :id="profileEnabled ? undefined : id"
:anchor-id="id"
:active="active"
compact
:href="profileEnabled ? href : undefined"
:name="displayName"
target="_self">
<template v-if="profileEnabled" #subname>
{{ name }}
</template>
<template v-if="loading" #indicator>
<NcLoadingIcon />
</template>
</NcListItem>
</template>
<script>
<script lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { defineComponent } from 'vue'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
const { profileEnabled } = loadState('user_status', 'profileEnabled', false)
const { profileEnabled } = loadState('user_status', 'profileEnabled', { profileEnabled: false })
export default {
name: 'ProfileUserMenuEntry',
export default defineComponent({
name: 'AccountMenuProfileEntry',
components: {
NcListItem,
NcLoadingIcon,
},
@ -58,10 +58,15 @@ export default {
},
},
data() {
setup() {
return {
profileEnabled,
displayName: getCurrentUser().displayName,
displayName: getCurrentUser()!.displayName,
}
},
data() {
return {
loading: false,
}
},
@ -83,41 +88,13 @@ export default {
}
},
handleProfileEnabledUpdate(profileEnabled) {
handleProfileEnabledUpdate(profileEnabled: boolean) {
this.profileEnabled = profileEnabled
},
handleDisplayNameUpdate(displayName) {
handleDisplayNameUpdate(displayName: string) {
this.displayName = displayName
},
},
}
})
</script>
<style lang="scss" scoped>
.menu-entry {
&__wrapper {
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
align-items: flex-start !important;
padding: 10px 12px 5px 12px !important;
height: var(--header-menu-item-height);
color: var(--color-text-maxcontrast);
&--link {
height: calc(var(--header-menu-item-height) * 1.5) !important;
color: var(--color-main-text);
}
}
&__content {
display: inline-flex;
gap: 0 10px;
}
&__displayname {
font-weight: bold;
}
}
</style>

@ -5,7 +5,7 @@
import Vue from 'vue'
import UserMenu from '../views/UserMenu.vue'
import AccountMenu from '../views/AccountMenu.vue'
export const setUp = () => {
const mountPoint = document.getElementById('user-menu')
@ -13,7 +13,7 @@ export const setUp = () => {
// eslint-disable-next-line no-new
new Vue({
el: mountPoint,
render: h => h(UserMenu),
render: h => h(AccountMenu),
})
}
}

@ -0,0 +1,239 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcHeaderMenu id="user-menu"
class="account-menu"
is-nav
:aria-label="t('core', 'Settings menu')"
:description="avatarDescription">
<template #trigger>
<!-- The `key` is a hack as NcAvatar does not handle updating the preloaded status on show status change -->
<NcAvatar :key="String(showUserStatus)"
class="account-menu__avatar"
disable-menu
disable-tooltip
:show-user-status="showUserStatus"
:user="currentUserId"
:preloaded-user-status="userStatus" />
</template>
<ul class="account-menu__list">
<AccountMenuProfileEntry :id="profileEntry.id"
:name="profileEntry.name"
:href="profileEntry.href"
:active="profileEntry.active" />
<AccountMenuEntry v-for="entry in otherEntries"
:id="entry.id"
:key="entry.id"
:name="entry.name"
:href="entry.href"
:active="entry.active"
:icon="entry.icon" />
</ul>
</NcHeaderMenu>
</template>
<script lang="ts">
import { getCurrentUser } from '@nextcloud/auth'
import { emit, subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { getCapabilities } from '@nextcloud/capabilities'
import { defineComponent } from 'vue'
import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
import axios from '@nextcloud/axios'
import logger from '../logger.js'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
import AccountMenuProfileEntry from '../components/AccountMenu/AccountMenuProfileEntry.vue'
import AccountMenuEntry from '../components/AccountMenu/AccountMenuEntry.vue'
interface ISettingsNavigationEntry {
/**
* id of the entry, used as HTML ID, for example, "settings"
*/
id: string
/**
* Label of the entry, for example, "Personal Settings"
*/
name: string
/**
* Icon of the entry, for example, "/apps/settings/img/personal.svg"
*/
icon: string
/**
* Type of the entry
*/
type: 'settings'|'link'|'guest'
/**
* Link of the entry, for example, "/settings/user"
*/
href: string
/**
* Whether the entry is active
*/
active: boolean
/**
* Order of the entry
*/
order: number
/**
* Number of unread pf this items
*/
unread: number
/**
* Classes for custom styling
*/
classes: string
}
const USER_DEFINABLE_STATUSES = getAllStatusOptions()
export default defineComponent({
name: 'AccountMenu',
components: {
AccountMenuEntry,
AccountMenuProfileEntry,
NcAvatar,
NcHeaderMenu,
},
setup() {
const settingsNavEntries = loadState<Record<string, ISettingsNavigationEntry>>('core', 'settingsNavEntries', {})
const { profile: profileEntry, ...otherEntries } = settingsNavEntries
return {
currentDisplayName: getCurrentUser()?.displayName ?? getCurrentUser()!.uid,
currentUserId: getCurrentUser()!.uid,
profileEntry,
otherEntries,
t,
}
},
data() {
return {
showUserStatus: false,
userStatus: {
status: null,
icon: null,
message: null,
},
}
},
computed: {
translatedUserStatus() {
return {
...this.userStatus,
status: this.translateStatus(this.userStatus.status),
}
},
avatarDescription() {
const description = [
t('core', 'Avatar of {displayName}', { displayName: this.currentDisplayName }),
...Object.values(this.translatedUserStatus).filter(Boolean),
].join(' — ')
return description
},
},
async created() {
if (!getCapabilities()?.user_status?.enabled) {
return
}
const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
try {
const response = await axios.get(url)
const { status, icon, message } = response.data.ocs.data
this.userStatus = { status, icon, message }
} catch (e) {
logger.error('Failed to load user status')
}
this.showUserStatus = true
},
mounted() {
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
emit('core:user-menu:mounted')
},
methods: {
handleUserStatusUpdated(state) {
if (this.currentUserId === state.userId) {
this.userStatus = {
status: state.status,
icon: state.icon,
message: state.message,
}
}
},
translateStatus(status) {
const statusMap = Object.fromEntries(
USER_DEFINABLE_STATUSES.map(({ type, label }) => [type, label]),
)
if (statusMap[status]) {
return statusMap[status]
}
return status
},
},
})
</script>
<style lang="scss" scoped>
:deep(#header-menu-user-menu) {
padding: 0 !important;
}
.account-menu {
:deep(button) {
// Normally header menus are slightly translucent when not active
// this is generally ok but for the avatar this is weird so fix the opacity
opacity: 1 !important;
// The avatar is just the "icon" of the button
// So we add the focus-visible manually
&:focus-visible {
.account-menu__avatar {
border: var(--border-width-input-focused) solid var(--color-background-plain-text);
}
}
}
// Ensure we do not wast space, as the header menu sets a default width of 350px
:deep(.header-menu__content) {
width: fit-content !important;
}
&__avatar {
&:hover {
// Add hover styles similar to the focus-visible style
border: var(--border-width-input-focused) solid var(--color-background-plain-text);
}
}
&__list {
display: inline-flex;
flex-direction: column;
padding-block: var(--default-grid-baseline) 0;
padding-inline: 0 var(--default-grid-baseline);
> :deep(li) {
box-sizing: border-box;
// basically "fit-content"
flex: 0 1;
}
}
}
</style>

@ -1,259 +0,0 @@
<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<NcHeaderMenu id="user-menu"
class="user-menu"
is-nav
:aria-label="t('core', 'Settings menu')"
:description="avatarDescription">
<template #trigger>
<NcAvatar v-if="!isLoadingUserStatus"
class="user-menu__avatar"
:disable-menu="true"
:disable-tooltip="true"
:user="userId"
:preloaded-user-status="userStatus" />
</template>
<ul>
<ProfileUserMenuEntry :id="profileEntry.id"
:name="profileEntry.name"
:href="profileEntry.href"
:active="profileEntry.active" />
<UserMenuEntry v-for="entry in otherEntries"
:id="entry.id"
:key="entry.id"
:name="entry.name"
:href="entry.href"
:active="entry.active"
:icon="entry.icon" />
</ul>
</NcHeaderMenu>
</template>
<script>
import axios from '@nextcloud/axios'
import { emit, subscribe } from '@nextcloud/event-bus'
import { loadState } from '@nextcloud/initial-state'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { getCapabilities } from '@nextcloud/capabilities'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcHeaderMenu from '@nextcloud/vue/dist/Components/NcHeaderMenu.js'
import { getAllStatusOptions } from '../../../apps/user_status/src/services/statusOptionsService.js'
import ProfileUserMenuEntry from '../components/UserMenu/ProfileUserMenuEntry.vue'
import UserMenuEntry from '../components/UserMenu/UserMenuEntry.vue'
import logger from '../logger.js'
/**
* @typedef SettingNavEntry
* @property {string} id - id of the entry, used as HTML ID, for example, "settings"
* @property {string} name - Label of the entry, for example, "Personal Settings"
* @property {string} icon - Icon of the entry, for example, "/apps/settings/img/personal.svg"
* @property {'settings'|'link'|'guest'} type - Type of the entry
* @property {string} href - Link of the entry, for example, "/settings/user"
* @property {boolean} active - Whether the entry is active
* @property {number} order - Order of the entry
* @property {number} unread - Number of unread pf this items
* @property {string} classes - Classes for custom styling
*/
/** @type {Record<string, SettingNavEntry>} */
const settingsNavEntries = loadState('core', 'settingsNavEntries', [])
const { profile: profileEntry, ...otherEntries } = settingsNavEntries
const translateStatus = (status) => {
const statusMap = Object.fromEntries(
getAllStatusOptions()
.map(({ type, label }) => [type, label]),
)
if (statusMap[status]) {
return statusMap[status]
}
return status
}
export default {
name: 'UserMenu',
components: {
NcAvatar,
NcHeaderMenu,
ProfileUserMenuEntry,
UserMenuEntry,
},
data() {
return {
profileEntry,
otherEntries,
displayName: getCurrentUser()?.displayName,
userId: getCurrentUser()?.uid,
isLoadingUserStatus: true,
userStatus: {
status: null,
icon: null,
message: null,
},
}
},
computed: {
translatedUserStatus() {
return {
...this.userStatus,
status: translateStatus(this.userStatus.status),
}
},
avatarDescription() {
const description = [
t('core', 'Avatar of {displayName}', { displayName: this.displayName }),
...Object.values(this.translatedUserStatus).filter(Boolean),
].join(' — ')
return description
},
},
async created() {
if (!getCapabilities()?.user_status?.enabled) {
this.isLoadingUserStatus = false
return
}
const url = generateOcsUrl('/apps/user_status/api/v1/user_status')
try {
const response = await axios.get(url)
const { status, icon, message } = response.data.ocs.data
this.userStatus = { status, icon, message }
} catch (e) {
logger.error('Failed to load user status')
}
this.isLoadingUserStatus = false
},
mounted() {
subscribe('user_status:status.updated', this.handleUserStatusUpdated)
emit('core:user-menu:mounted')
},
methods: {
handleUserStatusUpdated(state) {
if (this.userId === state.userId) {
this.userStatus = {
status: state.status,
icon: state.icon,
message: state.message,
}
}
},
},
}
</script>
<style lang="scss" scoped>
.user-menu {
&:deep {
.header-menu {
&__trigger {
opacity: 1 !important;
&:focus-visible {
.user-menu__avatar {
border: 2px solid var(--color-primary-element);
}
}
}
&__carret {
display: none !important;
}
&__content {
width: fit-content !important;
}
}
}
&__avatar {
&:active,
&:focus,
&:hover {
border: 2px solid var(--color-primary-element-text);
}
}
ul {
display: flex;
flex-direction: column;
gap: 2px;
&:deep {
li {
a,
button {
border-radius: 6px;
display: inline-flex;
align-items: center;
height: var(--header-menu-item-height);
color: var(--color-main-text);
padding: 10px 8px;
box-sizing: border-box;
white-space: nowrap;
position: relative;
width: 100%;
&:hover {
background-color: var(--color-background-hover);
}
&:focus-visible {
background-color: var(--color-background-hover) !important;
box-shadow: inset 0 0 0 2px var(--color-primary-element) !important;
outline: none !important;
}
&:active:not(:focus-visible),
&.active:not(:focus-visible) {
background-color: var(--color-primary-element);
color: var(--color-primary-element-text);
img {
filter: var(--primary-invert-if-dark);
}
}
span {
padding-bottom: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 210px;
}
img {
width: 16px;
height: 16px;
margin-right: 10px;
}
img {
filter: var(--background-invert-if-dark);
}
}
// Override global button styles
button {
background-color: transparent;
border: none;
font-weight: normal;
margin: 0;
}
}
}
}
}
</style>

@ -23,7 +23,9 @@ describe('Settings: Ensure only administrator can see the administration setting
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
getNextcloudUserMenu().find('#settings a').click()
getNextcloudUserMenu()
.findByRole('link', { name: /settings/i })
.click()
cy.url().should('match', /\/settings\/user$/)
cy.get('#app-navigation').should('be.visible').within(() => {
@ -45,7 +47,9 @@ describe('Settings: Ensure only administrator can see the administration setting
// I open the settings menu
getNextcloudUserMenuToggle().click()
// I navigate to the settings panel
getNextcloudUserMenu().find('#settings a').click()
getNextcloudUserMenu()
.findByRole('link', { name: /Personal settings/i })
.click()
cy.url().should('match', /\/settings\/user$/)
cy.get('#app-navigation').should('be.visible').within(() => {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/core-main.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -22,6 +22,7 @@ SPDX-FileCopyrightText: Jordan Harband
SPDX-FileCopyrightText: John-David Dalton <john.david.dalton@gmail.com> (http://allyoucanleet.com/)
SPDX-FileCopyrightText: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
SPDX-FileCopyrightText: Iskren Ivov Chernev <iskren.chernev@gmail.com> (https://github.com/ichernev)
SPDX-FileCopyrightText: Hiroki Osame
SPDX-FileCopyrightText: Guillaume Chau <guillaume.b.chau@gmail.com>
SPDX-FileCopyrightText: GitHub Inc.
SPDX-FileCopyrightText: Feross Aboukhadijeh
@ -228,6 +229,9 @@ This file is generated from multiple sources. Included packages:
- util
- version: 0.12.5
- license: MIT
- vue-frag
- version: 1.4.3
- license: MIT
- vue-loader
- version: 15.11.1
- license: MIT

File diff suppressed because one or more lines are too long