Signed-off-by: Grigorii K. Shartsev <me@shgk.me>tags/v29.0.0beta1
@@ -2,6 +2,7 @@ | |||
- @copyright 2021, 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 | |||
- | |||
@@ -22,47 +23,45 @@ | |||
<template> | |||
<div> | |||
<div class="email"> | |||
<NcInputField :id="inputIdWithDefault" | |||
ref="email" | |||
autocapitalize="none" | |||
autocomplete="email" | |||
:error="hasError || !!helperText" | |||
:helper-text="helperText || undefined" | |||
:label="inputPlaceholder" | |||
:placeholder="inputPlaceholder" | |||
spellcheck="false" | |||
:success="isSuccess" | |||
type="email" | |||
:value.sync="emailAddress" /> | |||
<div class="email__actions"> | |||
<NcActions :aria-label="actionsLabel" @close="showFederationSettings = false"> | |||
<template v-if="showFederationSettings"> | |||
<NcActionButton @click="showFederationSettings = false"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="mdiArrowLeft" /> | |||
</template> | |||
{{ t('settings', 'Back') }} | |||
</NcActionButton> | |||
<FederationControlActions :readable="propertyReadable" | |||
:additional="true" | |||
:additional-value="email" | |||
:disabled="federationDisabled" | |||
:handle-additional-scope-change="saveAdditionalEmailScope" | |||
:scope.sync="localScope" | |||
@update:scope="onScopeChange" /> | |||
</template> | |||
<template v-else> | |||
<NcActionButton v-if="!federationDisabled && !primary" | |||
@click="showFederationSettings = true"> | |||
<div class="email" :class="{ 'email--additional': !primary }"> | |||
<div v-if="!primary" class="email__label-container"> | |||
<label :for="inputIdWithDefault">{{ inputPlaceholder }}</label> | |||
<FederationControl v-if="!federationDisabled && !primary" | |||
:readable="propertyReadable" | |||
:additional="true" | |||
:additional-value="email" | |||
:disabled="federationDisabled" | |||
:handle-additional-scope-change="saveAdditionalEmailScope" | |||
:scope.sync="localScope" | |||
@update:scope="onScopeChange" /> | |||
</div> | |||
<div class="email__input-container"> | |||
<NcTextField :id="inputIdWithDefault" | |||
ref="email" | |||
class="email__input" | |||
autocapitalize="none" | |||
autocomplete="email" | |||
:error="hasError || !!helperText" | |||
:helper-text="helperTextWithNonConfirmed" | |||
label-outside | |||
:placeholder="inputPlaceholder" | |||
spellcheck="false" | |||
:success="isSuccess" | |||
type="email" | |||
:value.sync="emailAddress" /> | |||
<div class="email__actions"> | |||
<NcActions :aria-label="actionsLabel"> | |||
<NcActionButton v-if="!primary || !isNotificationEmail" | |||
close-after-click | |||
:disabled="!isConfirmedAddress" | |||
@click="setNotificationMail"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="mdiLock" /> | |||
<NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" /> | |||
<NcIconSvgWrapper v-else :path="mdiStarOutline" /> | |||
</template> | |||
{{ t('settings', 'Change scope level of {property}', { property: propertyReadable.toLocaleLowerCase() }) }} | |||
{{ setNotificationMailLabel }} | |||
</NcActionButton> | |||
<NcActionCaption v-if="!isConfirmedAddress" | |||
:name="t('settings', 'This address is not confirmed')" /> | |||
<NcActionButton close-after-click | |||
:disabled="deleteDisabled" | |||
@click="deleteEmail"> | |||
@@ -71,18 +70,8 @@ | |||
</template> | |||
{{ deleteEmailLabel }} | |||
</NcActionButton> | |||
<NcActionButton v-if="!primary || !isNotificationEmail" | |||
close-after-click | |||
:disabled="!isConfirmedAddress" | |||
@click="setNotificationMail"> | |||
<template #icon> | |||
<NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" /> | |||
<NcIconSvgWrapper v-else :path="mdiStarOutline" /> | |||
</template> | |||
{{ setNotificationMailLabel }} | |||
</NcActionButton> | |||
</template> | |||
</NcActions> | |||
</NcActions> | |||
</div> | |||
</div> | |||
</div> | |||
@@ -95,13 +84,14 @@ | |||
<script> | |||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.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 NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' | |||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js' | |||
import debounce from 'debounce' | |||
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 { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js' | |||
@@ -121,10 +111,9 @@ export default { | |||
components: { | |||
NcActions, | |||
NcActionButton, | |||
NcActionCaption, | |||
NcIconSvgWrapper, | |||
NcInputField, | |||
FederationControlActions, | |||
NcTextField, | |||
FederationControl, | |||
}, | |||
props: { | |||
@@ -213,6 +202,20 @@ export default { | |||
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() { | |||
if (this.isNotificationEmail) { | |||
return t('settings', 'Unset as primary email') | |||
@@ -259,7 +262,8 @@ export default { | |||
methods: { | |||
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) { | |||
return | |||
} | |||
@@ -403,16 +407,29 @@ export default { | |||
<style lang="scss" scoped> | |||
.email { | |||
display: flex; | |||
flex-direction: row; | |||
align-items: start; | |||
gap: 4px; | |||
&__label-container { | |||
height: var(--default-clickable-area); | |||
display: flex; | |||
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 { | |||
display: flex; | |||
gap: 0 2px; | |||
margin-right: 5px; | |||
margin-top: 6px; | |||
position: absolute; | |||
inset-block-start: 0; | |||
inset-inline-end: 0; | |||
} | |||
} | |||
</style> |
@@ -2,6 +2,7 @@ | |||
- @copyright 2021, 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 | |||
- | |||
@@ -21,7 +22,7 @@ | |||
--> | |||
<template> | |||
<section> | |||
<section class="section-emails"> | |||
<HeaderBar :input-id="inputId" | |||
:readable="primaryEmail.readable" | |||
:is-editable="true" | |||
@@ -45,10 +46,10 @@ | |||
</span> | |||
<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 --> | |||
<Email v-for="(additionalEmail, index) in additionalEmails" | |||
:key="additionalEmail.key" | |||
class="section-emails__additional-email" | |||
:index="index" | |||
:scope.sync="additionalEmail.scope" | |||
:email.sync="additionalEmail.value" | |||
@@ -196,12 +197,11 @@ export default { | |||
</script> | |||
<style lang="scss" scoped> | |||
section { | |||
.section-emails { | |||
padding: 10px 10px; | |||
.additional-emails-label { | |||
display: block; | |||
margin-top: 16px; | |||
&__additional-email { | |||
margin-top: calc(var(--default-grid-baseline) * 3); | |||
} | |||
} | |||
</style> |
@@ -3,6 +3,7 @@ | |||
- | |||
- @author Christopher Ng <chrng8@gmail.com> | |||
- @author Ferdinand Thiessen <opensource@fthiessen.de> | |||
- @author Grigorii K. Shartsev <me@shgk.me> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
@@ -24,39 +25,60 @@ | |||
<template> | |||
<NcActions ref="federationActions" | |||
class="federation-actions" | |||
:class="{ 'federation-actions--additional': additional }" | |||
:aria-label="ariaLabel" | |||
:disabled="disabled"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="scopeIcon" /> | |||
</template> | |||
<FederationControlActions :additional="additional" | |||
:additional-value="additionalValue" | |||
:handle-additional-scope-change="handleAdditionalScopeChange" | |||
:readable="readable" | |||
:scope="scope" | |||
@update:scope="onUpdateScope" /> | |||
<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> | |||
</NcActions> | |||
</template> | |||
<script> | |||
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 { loadState } from '@nextcloud/initial-state' | |||
import { | |||
ACCOUNT_PROPERTY_READABLE_ENUM, | |||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, | |||
PROFILE_READABLE_ENUM, | |||
PROPERTY_READABLE_KEYS_ENUM, | |||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, | |||
SCOPE_PROPERTY_ENUM, | |||
SCOPE_ENUM, | |||
UNPUBLISHED_READABLE_PROPERTIES, | |||
} 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 { | |||
name: 'FederationControl', | |||
components: { | |||
NcActions, | |||
NcActionButton, | |||
NcIconSvgWrapper, | |||
FederationControlActions, | |||
}, | |||
props: { | |||
@@ -87,9 +109,12 @@ export default { | |||
}, | |||
}, | |||
emits: ['update:scope'], | |||
data() { | |||
return { | |||
readableLowerCase: this.readable.toLocaleLowerCase(), | |||
initialScope: this.scope, | |||
} | |||
}, | |||
@@ -105,14 +130,82 @@ export default { | |||
scopeIcon() { | |||
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: { | |||
onUpdateScope(scope) { | |||
async changeScope(scope) { | |||
this.$emit('update:scope', scope) | |||
if (!this.additional) { | |||
await this.updatePrimaryScope(scope) | |||
} else { | |||
await this.updateAdditionalScope(scope) | |||
} | |||
// TODO: provide focus method from NcActions | |||
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> |
@@ -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')) | |||
getVisibilityButton(property) | |||
.click() | |||
cy.get('ul[role="dialog"') | |||
cy.get('ul[role="menu"]') | |||
.contains('button', active) | |||
.should('have.attr', 'aria-pressed', 'true') | |||
.should('have.attr', 'aria-checked', 'true') | |||
// close menu | |||
getVisibilityButton(property) | |||
@@ -74,7 +74,7 @@ const validateActiveVisibility = (property: string, active: Visibility) => { | |||
const setActiveVisibility = (property: string, active: Visibility) => { | |||
getVisibilityButton(property) | |||
.click() | |||
cy.get('ul[role="dialog"') | |||
cy.get('ul[role="menu"]') | |||
.contains('button', active) | |||
.click({ force: true }) | |||
handlePasswordConfirmation(user.password) |