fix(settings): emails actions a11y and design

Signed-off-by: Grigorii K. Shartsev <me@shgk.me>
pull/43944/head
Grigorii K. Shartsev 2024-03-01 18:59:03 +07:00
parent 7efb36bd53
commit 680f439f73
5 changed files with 194 additions and 265 deletions

@ -2,6 +2,7 @@
- @copyright 2021, Christopher Ng <chrng8@gmail.com> - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- @author Christopher Ng <chrng8@gmail.com> - @author Christopher Ng <chrng8@gmail.com>
- @author Grigorii K. Shartsev <me@shgk.me>
- -
- @license GNU AGPL version 3 or any later version - @license GNU AGPL version 3 or any later version
- -
@ -22,47 +23,45 @@
<template> <template>
<div> <div>
<div class="email"> <div class="email" :class="{ 'email--additional': !primary }">
<NcInputField :id="inputIdWithDefault" <div v-if="!primary" class="email__label-container">
ref="email" <label :for="inputIdWithDefault">{{ inputPlaceholder }}</label>
autocapitalize="none" <FederationControl v-if="!federationDisabled && !primary"
autocomplete="email" :readable="propertyReadable"
:error="hasError || !!helperText" :additional="true"
:helper-text="helperText || undefined" :additional-value="email"
:label="inputPlaceholder" :disabled="federationDisabled"
:placeholder="inputPlaceholder" :handle-additional-scope-change="saveAdditionalEmailScope"
spellcheck="false" :scope.sync="localScope"
:success="isSuccess" @update:scope="onScopeChange" />
type="email" </div>
:value.sync="emailAddress" /> <div class="email__input-container">
<NcTextField :id="inputIdWithDefault"
<div class="email__actions"> ref="email"
<NcActions :aria-label="actionsLabel" @close="showFederationSettings = false"> class="email__input"
<template v-if="showFederationSettings"> autocapitalize="none"
<NcActionButton @click="showFederationSettings = false"> autocomplete="email"
<template #icon> :error="hasError || !!helperText"
<NcIconSvgWrapper :path="mdiArrowLeft" /> :helper-text="helperTextWithNonConfirmed"
</template> label-outside
{{ t('settings', 'Back') }} :placeholder="inputPlaceholder"
</NcActionButton> spellcheck="false"
<FederationControlActions :readable="propertyReadable" :success="isSuccess"
:additional="true" type="email"
:additional-value="email" :value.sync="emailAddress" />
:disabled="federationDisabled"
:handle-additional-scope-change="saveAdditionalEmailScope" <div class="email__actions">
:scope.sync="localScope" <NcActions :aria-label="actionsLabel">
@update:scope="onScopeChange" /> <NcActionButton v-if="!primary || !isNotificationEmail"
</template> close-after-click
<template v-else> :disabled="!isConfirmedAddress"
<NcActionButton v-if="!federationDisabled && !primary" @click="setNotificationMail">
@click="showFederationSettings = true">
<template #icon> <template #icon>
<NcIconSvgWrapper :path="mdiLock" /> <NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" />
<NcIconSvgWrapper v-else :path="mdiStarOutline" />
</template> </template>
{{ t('settings', 'Change scope level of {property}', { property: propertyReadable.toLocaleLowerCase() }) }} {{ setNotificationMailLabel }}
</NcActionButton> </NcActionButton>
<NcActionCaption v-if="!isConfirmedAddress"
:name="t('settings', 'This address is not confirmed')" />
<NcActionButton close-after-click <NcActionButton close-after-click
:disabled="deleteDisabled" :disabled="deleteDisabled"
@click="deleteEmail"> @click="deleteEmail">
@ -71,18 +70,8 @@
</template> </template>
{{ deleteEmailLabel }} {{ deleteEmailLabel }}
</NcActionButton> </NcActionButton>
<NcActionButton v-if="!primary || !isNotificationEmail" </NcActions>
close-after-click </div>
:disabled="!isConfirmedAddress"
@click="setNotificationMail">
<template #icon>
<NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" />
<NcIconSvgWrapper v-else :path="mdiStarOutline" />
</template>
{{ setNotificationMailLabel }}
</NcActionButton>
</template>
</NcActions>
</div> </div>
</div> </div>
@ -95,13 +84,14 @@
<script> <script>
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActionCaption from '@nextcloud/vue/dist/Components/NcActionCaption.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
import debounce from 'debounce' import debounce from 'debounce'
import { mdiArrowLeft, mdiLock, mdiStar, mdiStarOutline, mdiTrashCan } from '@mdi/js' import { mdiArrowLeft, mdiLock, mdiStar, mdiStarOutline, mdiTrashCan } from '@mdi/js'
import FederationControlActions from '../shared/FederationControlActions.vue'
import FederationControl from '../shared/FederationControl.vue'
import { handleError } from '../../../utils/handlers.js' import { handleError } from '../../../utils/handlers.js'
import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js' import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js'
@ -121,10 +111,9 @@ export default {
components: { components: {
NcActions, NcActions,
NcActionButton, NcActionButton,
NcActionCaption,
NcIconSvgWrapper, NcIconSvgWrapper,
NcInputField, NcTextField,
FederationControlActions, FederationControl,
}, },
props: { props: {
@ -213,6 +202,20 @@ export default {
return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED
}, },
isNotConfirmedHelperText() {
if (!this.isConfirmedAddress) {
return t('settings', 'This address is not confirmed')
}
return ''
},
helperTextWithNonConfirmed() {
if (this.helperText || this.hasError || this.isSuccess) {
return this.helperText || ''
}
return this.isNotConfirmedHelperText
},
setNotificationMailLabel() { setNotificationMailLabel() {
if (this.isNotificationEmail) { if (this.isNotificationEmail) {
return t('settings', 'Unset as primary email') return t('settings', 'Unset as primary email')
@ -259,7 +262,8 @@ export default {
methods: { methods: {
debounceEmailChange: debounce(async function(email) { debounceEmailChange: debounce(async function(email) {
this.helperText = this.$refs.email?.$refs.input?.validationMessage || null // TODO: provide method to get native input in NcTextField
this.helperText = this.$refs.email.$refs.inputField.$refs.input.validationMessage || null
if (this.helperText !== null) { if (this.helperText !== null) {
return return
} }
@ -403,16 +407,29 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.email { .email {
display: flex; &__label-container {
flex-direction: row; height: var(--default-clickable-area);
align-items: start; display: flex;
gap: 4px; flex-direction: row;
align-items: center;
gap: calc(var(--default-grid-baseline) * 2);
}
&__input-container {
position: relative;
}
&__input {
// TODO: provide a way to hide status icon or combine it with trailing button in NcInputField
:deep(.input-field__icon--trailing) {
display: none;
}
}
&__actions { &__actions {
display: flex; position: absolute;
gap: 0 2px; inset-block-start: 0;
margin-right: 5px; inset-inline-end: 0;
margin-top: 6px;
} }
} }
</style> </style>

@ -2,6 +2,7 @@
- @copyright 2021, Christopher Ng <chrng8@gmail.com> - @copyright 2021, Christopher Ng <chrng8@gmail.com>
- -
- @author Christopher Ng <chrng8@gmail.com> - @author Christopher Ng <chrng8@gmail.com>
- @author Grigorii K. Shartsev <me@shgk.me>
- -
- @license GNU AGPL version 3 or any later version - @license GNU AGPL version 3 or any later version
- -
@ -21,7 +22,7 @@
--> -->
<template> <template>
<section> <section class="section-emails">
<HeaderBar :input-id="inputId" <HeaderBar :input-id="inputId"
:readable="primaryEmail.readable" :readable="primaryEmail.readable"
:is-editable="true" :is-editable="true"
@ -45,10 +46,10 @@
</span> </span>
<template v-if="additionalEmails.length"> <template v-if="additionalEmails.length">
<em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em>
<!-- TODO use unique key for additional email when uniqueness can be guaranteed, see https://github.com/nextcloud/server/issues/26866 --> <!-- TODO use unique key for additional email when uniqueness can be guaranteed, see https://github.com/nextcloud/server/issues/26866 -->
<Email v-for="(additionalEmail, index) in additionalEmails" <Email v-for="(additionalEmail, index) in additionalEmails"
:key="additionalEmail.key" :key="additionalEmail.key"
class="section-emails__additional-email"
:index="index" :index="index"
:scope.sync="additionalEmail.scope" :scope.sync="additionalEmail.scope"
:email.sync="additionalEmail.value" :email.sync="additionalEmail.value"
@ -196,12 +197,11 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
section { .section-emails {
padding: 10px 10px; padding: 10px 10px;
.additional-emails-label { &__additional-email {
display: block; margin-top: calc(var(--default-grid-baseline) * 3);
margin-top: 16px;
} }
} }
</style> </style>

@ -3,6 +3,7 @@
- -
- @author Christopher Ng <chrng8@gmail.com> - @author Christopher Ng <chrng8@gmail.com>
- @author Ferdinand Thiessen <opensource@fthiessen.de> - @author Ferdinand Thiessen <opensource@fthiessen.de>
- @author Grigorii K. Shartsev <me@shgk.me>
- -
- @license GNU AGPL version 3 or any later version - @license GNU AGPL version 3 or any later version
- -
@ -24,39 +25,60 @@
<template> <template>
<NcActions ref="federationActions" <NcActions ref="federationActions"
class="federation-actions" class="federation-actions"
:class="{ 'federation-actions--additional': additional }"
:aria-label="ariaLabel" :aria-label="ariaLabel"
:disabled="disabled"> :disabled="disabled">
<template #icon> <template #icon>
<NcIconSvgWrapper :path="scopeIcon" /> <NcIconSvgWrapper :path="scopeIcon" />
</template> </template>
<FederationControlActions :additional="additional"
:additional-value="additionalValue" <NcActionButton v-for="federationScope in federationScopes"
:handle-additional-scope-change="handleAdditionalScopeChange" :key="federationScope.name"
:readable="readable" :close-after-click="true"
:scope="scope" :disabled="!supportedScopes.includes(federationScope.name)"
@update:scope="onUpdateScope" /> :name="federationScope.displayName"
type="radio"
:value="federationScope.name"
:model-value="scope"
@update:modelValue="changeScope">
<template #icon>
<NcIconSvgWrapper :path="federationScope.icon" />
</template>
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
</NcActionButton>
</NcActions> </NcActions>
</template> </template>
<script> <script>
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import { loadState } from '@nextcloud/initial-state'
import { import {
ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM,
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
PROFILE_READABLE_ENUM, PROFILE_READABLE_ENUM,
PROPERTY_READABLE_KEYS_ENUM,
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
SCOPE_PROPERTY_ENUM, SCOPE_PROPERTY_ENUM,
SCOPE_ENUM,
UNPUBLISHED_READABLE_PROPERTIES,
} from '../../../constants/AccountPropertyConstants.js' } from '../../../constants/AccountPropertyConstants.js'
import FederationControlActions from './FederationControlActions.vue' import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
import { handleError } from '../../../utils/handlers.js'
const {
federationEnabled,
lookupServerUploadEnabled,
} = loadState('settings', 'accountParameters', {})
export default { export default {
name: 'FederationControl', name: 'FederationControl',
components: { components: {
NcActions, NcActions,
NcActionButton,
NcIconSvgWrapper, NcIconSvgWrapper,
FederationControlActions,
}, },
props: { props: {
@ -87,9 +109,12 @@ export default {
}, },
}, },
emits: ['update:scope'],
data() { data() {
return { return {
readableLowerCase: this.readable.toLocaleLowerCase(), readableLowerCase: this.readable.toLocaleLowerCase(),
initialScope: this.scope,
} }
}, },
@ -105,14 +130,82 @@ export default {
scopeIcon() { scopeIcon() {
return SCOPE_PROPERTY_ENUM[this.scope].icon return SCOPE_PROPERTY_ENUM[this.scope].icon
}, },
federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM)
},
supportedScopes() {
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
return scopes
}
if (federationEnabled) {
scopes.push(SCOPE_ENUM.FEDERATED)
}
if (lookupServerUploadEnabled) {
scopes.push(SCOPE_ENUM.PUBLISHED)
}
return scopes
},
}, },
methods: { methods: {
onUpdateScope(scope) { async changeScope(scope) {
this.$emit('update:scope', scope) this.$emit('update:scope', scope)
if (!this.additional) {
await this.updatePrimaryScope(scope)
} else {
await this.updateAdditionalScope(scope)
}
// TODO: provide focus method from NcActions // TODO: provide focus method from NcActions
this.$refs.federationActions.$refs.menuButton.$el.focus() this.$refs.federationActions.$refs.menuButton.$el.focus()
}, },
async updatePrimaryScope(scope) {
try {
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
error: e,
})
}
},
async updateAdditionalScope(scope) {
try {
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
error: e,
})
}
},
handleResponse({ scope, status, errorMessage, error }) {
if (status === 'ok') {
this.initialScope = scope
} else {
this.$emit('update:scope', this.initialScope)
handleError(error, errorMessage)
}
},
}, },
} }
</script> </script>

@ -1,181 +0,0 @@
<!--
- @copyright 2021, Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license GNU AGPL version 3 or any later version
-
- 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/>.
-
-->
<template>
<Fragment>
<NcActionButton v-for="federationScope in federationScopes"
:key="federationScope.name"
:close-after-click="true"
:disabled="!supportedScopes.includes(federationScope.name)"
:name="federationScope.displayName"
type="radio"
:value="federationScope.name"
:model-value="scope"
@update:modelValue="changeScope">
<template #icon>
<NcIconSvgWrapper :path="federationScope.icon" />
</template>
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }}
</NcActionButton>
</Fragment>
</template>
<script>
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
import { loadState } from '@nextcloud/initial-state'
import { Fragment } from 'vue-frag'
import {
ACCOUNT_PROPERTY_READABLE_ENUM,
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM,
PROFILE_READABLE_ENUM,
PROPERTY_READABLE_KEYS_ENUM,
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM,
SCOPE_ENUM, SCOPE_PROPERTY_ENUM,
UNPUBLISHED_READABLE_PROPERTIES,
} from '../../../constants/AccountPropertyConstants.js'
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js'
import { handleError } from '../../../utils/handlers.js'
const {
federationEnabled,
lookupServerUploadEnabled,
} = loadState('settings', 'accountParameters', {})
export default {
name: 'FederationControlActions',
components: {
Fragment,
NcActionButton,
NcIconSvgWrapper,
},
props: {
readable: {
type: String,
required: true,
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY,
},
additional: {
type: Boolean,
default: false,
},
additionalValue: {
type: String,
default: '',
},
handleAdditionalScopeChange: {
type: Function,
default: null,
},
scope: {
type: String,
required: true,
},
},
data() {
return {
readableLowerCase: this.readable.toLocaleLowerCase(),
initialScope: this.scope,
}
},
computed: {
federationScopes() {
return Object.values(SCOPE_PROPERTY_ENUM)
},
supportedScopes() {
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable]
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) {
return scopes
}
if (federationEnabled) {
scopes.push(SCOPE_ENUM.FEDERATED)
}
if (lookupServerUploadEnabled) {
scopes.push(SCOPE_ENUM.PUBLISHED)
}
return scopes
},
},
methods: {
async changeScope(scope) {
this.$emit('update:scope', scope)
if (!this.additional) {
await this.updatePrimaryScope(scope)
} else {
await this.updateAdditionalScope(scope)
}
},
async updatePrimaryScope(scope) {
try {
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }),
error: e,
})
}
},
async updateAdditionalScope(scope) {
try {
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope)
this.handleResponse({
scope,
status: responseData.ocs?.meta?.status,
})
} catch (e) {
this.handleResponse({
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }),
error: e,
})
}
},
handleResponse({ scope, status, errorMessage, error }) {
if (status === 'ok') {
this.initialScope = scope
} else {
this.$emit('update:scope', this.initialScope)
handleError(error, errorMessage)
}
},
},
}
</script>

@ -57,9 +57,9 @@ const validateActiveVisibility = (property: string, active: Visibility) => {
.and('match', new RegExp(`current scope is ${active}`, 'i')) .and('match', new RegExp(`current scope is ${active}`, 'i'))
getVisibilityButton(property) getVisibilityButton(property)
.click() .click()
cy.get('ul[role="dialog"') cy.get('ul[role="menu"]')
.contains('button', active) .contains('button', active)
.should('have.attr', 'aria-pressed', 'true') .should('have.attr', 'aria-checked', 'true')
// close menu // close menu
getVisibilityButton(property) getVisibilityButton(property)
@ -74,7 +74,7 @@ const validateActiveVisibility = (property: string, active: Visibility) => {
const setActiveVisibility = (property: string, active: Visibility) => { const setActiveVisibility = (property: string, active: Visibility) => {
getVisibilityButton(property) getVisibilityButton(property)
.click() .click()
cy.get('ul[role="dialog"') cy.get('ul[role="menu"]')
.contains('button', active) .contains('button', active)
.click({ force: true }) .click({ force: true })
handlePasswordConfirmation(user.password) handlePasswordConfirmation(user.password)