diff options
author | Christopher Ng <chrng8@gmail.com> | 2021-06-29 21:46:37 +0300 |
---|---|---|
committer | John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com> | 2021-07-15 11:16:06 +0300 |
commit | 44763576b1180c84b645fcd7017eceaeeeedb094 (patch) | |
tree | 7db924e79a46d49ddbc2f2fbb9e672238a6f1ea7 /apps/settings/src | |
parent | de6e55075bc940ad4b576ba1874ad58960dba11c (diff) |
Make emails Vuetiful
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Diffstat (limited to 'apps/settings/src')
6 files changed, 810 insertions, 0 deletions
diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/AddButton.vue b/apps/settings/src/components/PersonalInfo/EmailSection/AddButton.vue new file mode 100644 index 00000000000..83a293ed234 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/AddButton.vue @@ -0,0 +1,78 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @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> + <button + :disabled="disabled" + @click.stop.prevent="onClick"> + <span class="icon icon-add" /> + {{ t('settings', 'Add') }} + </button> +</template> + +<script> +export default { + name: 'AddButton', + + props: { + disabled: { + type: Boolean, + default: true, + }, + }, + + methods: { + onClick(e) { + this.$emit('click', e) + }, + }, +} +</script> + +<style lang="scss" scoped> + button { + height: 44px; + padding: 0 16px; + border: none; + background-color: transparent; + + &:hover { + background-color: rgba(127, 127, 127, .15); + } + + &:enabled { + opacity: 0.4 !important; + + .icon { + opacity: 0.8 !important; + } + } + + &:enabled:hover { + background-color: rgba(127, 127, 127, .25); + opacity: 0.8 !important; + } + + .icon { + margin-right: 8px; + } + } +</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue new file mode 100644 index 00000000000..7ae01908013 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue @@ -0,0 +1,323 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @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> + <div> + <div class="email-container"> + <input + ref="email" + type="email" + :name="inputName" + :placeholder="inputPlaceholder" + :value="email" + autocapitalize="none" + autocomplete="on" + autocorrect="off" + required="true" + @input="onEmailChange"> + + <div class="email-actions-container"> + <transition name="fade"> + <span v-if="showCheckmarkIcon" class="icon-checkmark" /> + <span v-else-if="showErrorIcon" class="icon-error" /> + </transition> + + <FederationControl v-if="!primary" + class="federation-control" + :disabled="federationDisabled" + :email="email" + :scope.sync="localScope" + @update:scope="onScopeChange" /> + + <Actions + class="actions-email" + :aria-label="t('settings', 'Email options')" + :disabled="deleteDisabled" + :force-menu="true"> + <ActionButton + :aria-label="deleteEmailLabel" + :close-after-click="true" + icon="icon-delete" + @click.stop.prevent="deleteEmail"> + {{ deleteEmailLabel }} + </ActionButton> + </Actions> + </div> + </div> + + <em v-if="primary"> + {{ t('settings', 'Primary email for password reset and notifications') }} + </em> + </div> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import { showError } from '@nextcloud/dialogs' +import debounce from 'debounce' + +import FederationControl from './FederationControl' +import { savePrimaryEmail, saveAdditionalEmail, updateAdditionalEmail, removeAdditionalEmail } from '../../../service/PersonalInfoService' + +export default { + name: 'Email', + + components: { + Actions, + ActionButton, + FederationControl, + }, + + props: { + email: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + primary: { + type: Boolean, + default: false, + }, + index: { + type: Number, + default: 0, + }, + }, + + data() { + return { + initialEmail: this.email, + localScope: this.scope, + showCheckmarkIcon: false, + showErrorIcon: false, + } + }, + + computed: { + inputName() { + if (this.primary) { + return 'email' + } + return 'additionalEmail[]' + }, + + inputPlaceholder() { + if (this.primary) { + return t('settings', 'Your email address') + } + return t('settings', 'Additional email address {index}', { index: this.index + 1 }) + }, + + federationDisabled() { + return !this.initialEmail + }, + + deleteDisabled() { + return !this.containsNoWhitespace(this.email) + }, + + deleteEmailLabel() { + if (this.primary) { + return t('settings', 'Remove primary email') + } + return t('settings', 'Delete email') + }, + }, + + methods: { + onEmailChange(e) { + this.$emit('update:email', e.target.value) + // $nextTick() ensures that references to this.email further down the chain give the correct non-outdated value + this.$nextTick(() => this.debounceEmailChange()) + }, + + debounceEmailChange: debounce(async function() { + if ((this.$refs.email?.checkValidity() && this.containsNoWhitespace(this.email)) || this.email === '') { + if (this.primary) { + await this.updatePrimaryEmail() + } else { + if (this.initialEmail && this.email === '') { + await this.deleteAdditionalEmail() + } else if (this.initialEmail === '') { + await this.addAdditionalEmail() + } else { + await this.updateAdditionalEmail() + } + } + } + }, 500), + + async deleteEmail() { + if (this.primary) { + this.$emit('update:email', '') + this.$nextTick(async() => await this.updatePrimaryEmail()) + } else { + await this.deleteAdditionalEmail() + } + }, + + async updatePrimaryEmail() { + try { + const responseData = await savePrimaryEmail(this.email) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + if (this.email === '') { + this.handleResponse('error', 'Unable to delete primary email address', e) + } else { + this.handleResponse('error', 'Unable to update primary email address', e) + } + } + }, + + async addAdditionalEmail() { + try { + const responseData = await saveAdditionalEmail(this.email) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse('error', 'Unable to add additional email address', e) + } + }, + + async updateAdditionalEmail() { + try { + const responseData = await updateAdditionalEmail(this.initialEmail, this.email) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse('error', 'Unable to update additional email address', e) + } + }, + + async deleteAdditionalEmail() { + try { + const responseData = await removeAdditionalEmail(this.initialEmail) + this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse('error', 'Unable to delete additional email address', e) + } + }, + + containsNoWhitespace(string) { + return /^\S+$/.test(string) + }, + + handleDeleteAdditionalEmail(status) { + if (status === 'ok') { + this.$emit('deleteAdditionalEmail') + } else { + this.handleResponse('error', 'Unable to delete additional email address', {}) + } + }, + + handleResponse(status, errorMessage, error) { + if (status === 'ok') { + // Ensure that local initialEmail state reflects server state + this.initialEmail = this.email + this.showCheckmarkIcon = true + setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + } else { + showError(t('settings', errorMessage)) + this.logger.error(errorMessage, error) + this.showErrorIcon = true + setTimeout(() => { this.showErrorIcon = false }, 2000) + } + }, + + onScopeChange(scope) { + this.$emit('update:scope', scope) + }, + }, +} +</script> + +<style lang="scss" scoped> + .email-container { + display: grid; + align-items: center; + + input[type=email] { + grid-area: 1 / 1; + } + + .email-actions-container { + grid-area: 1 / 1; + justify-self: flex-end; + height: 30px; + + display: flex; + gap: 0 2px; + margin-right: 5px; + + .actions-email { + opacity: 0.4 !important; + + &:hover { + opacity: 0.8 !important; + } + + &::v-deep button { + height: 30px !important; + min-height: 30px !important; + width: 30px !important; + min-width: 30px !important; + } + } + + .federation-control { + &::v-deep button { + // TODO remove this hack + padding-bottom: 7px; + height: 30px !important; + min-height: 30px !important; + width: 30px !important; + min-width: 30px !important; + } + } + + .icon-checkmark, + .icon-error { + height: 30px !important; + min-height: 30px !important; + width: 30px !important; + min-width: 30px !important; + top: 0; + right: 0; + float: none; + } + } + } + + .fade-enter-active { + transition: opacity 200ms ease-out; + } + + .fade-leave-active { + transition: opacity 300ms ease-out; + } + + .fade-enter, + .fade-leave-to { + opacity: 0; + } +</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue new file mode 100644 index 00000000000..700036872b4 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue @@ -0,0 +1,117 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @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> + <form + ref="form" + class="section" + @submit.stop.prevent="() => {}"> + <HeaderBar + :can-edit-emails="isDisplayNameChangeSupported" + :is-valid-form="isValidForm" + :scope.sync="primaryEmail.scope" + @addAdditionalEmail="onAddAdditionalEmail" /> + + <template v-if="isDisplayNameChangeSupported"> + <Email + :primary="true" + :scope.sync="primaryEmail.scope" + :email.sync="primaryEmail.value" + @update:email="updateFormValidity" /> + <Email v-for="(additionalEmail, index) in additionalEmails" + :key="index" + :index="index" + :scope.sync="additionalEmail.scope" + :email.sync="additionalEmail.value" + @update:email="updateFormValidity" + @deleteAdditionalEmail="onDeleteAdditionalEmail(index)" /> + </template> + + <span v-else> + {{ primaryEmail.value || t('settings', 'No email address set') }} + </span> + </form> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import '@nextcloud/dialogs/styles/toast.scss' + +import HeaderBar from './HeaderBar' +import Email from './Email' +import { DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants' + +const { additionalEmails, primaryEmail } = loadState('settings', 'emails', {}) +const accountParams = loadState('settings', 'accountParameters', {}) + +export default { + name: 'EmailSection', + + components: { + HeaderBar, + Email, + }, + + data() { + return { + accountParams, + additionalEmails, + primaryEmail, + isValidForm: true, + } + }, + + computed: { + isDisplayNameChangeSupported() { + return this.accountParams.displayNameChangeSupported + }, + }, + + mounted() { + this.$nextTick(() => this.updateFormValidity()) + }, + + methods: { + onAddAdditionalEmail() { + if (this.$refs.form?.checkValidity()) { + this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE }) + this.$nextTick(() => this.updateFormValidity()) + } + }, + + onDeleteAdditionalEmail(index) { + this.$delete(this.additionalEmails, index) + }, + + updateFormValidity() { + this.isValidForm = this.$refs.form?.checkValidity() + }, + }, +} +</script> + +<style lang="scss" scoped> + form::v-deep button { + &:disabled { + cursor: default; + } + } +</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/FederationControl.vue b/apps/settings/src/components/PersonalInfo/EmailSection/FederationControl.vue new file mode 100644 index 00000000000..87496a81160 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/FederationControl.vue @@ -0,0 +1,160 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @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> + <Actions + class="actions-federation" + :aria-label="t('settings', 'Change privacy level of email')" + :default-icon="scopeIcon" + :disabled="disabled"> + <ActionButton v-for="federationScope in federationScopes" + :key="federationScope.name" + class="forced-action" + :class="{ 'forced-active': scope === federationScope.name }" + :aria-label="federationScope.tooltip" + :close-after-click="true" + :icon="federationScope.iconClass" + :title="federationScope.displayName" + @click.stop.prevent="changeScope(federationScope.name)"> + {{ federationScope.tooltip }} + </ActionButton> + </Actions> +</template> + +<script> +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import { showError } from '@nextcloud/dialogs' + +import { SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryEmailScope, saveAdditionalEmailScope } from '../../../service/PersonalInfoService' + +// TODO hardcoded for email, should abstract this for other sections +const excludedScopes = [SCOPE_ENUM.PRIVATE] + +export default { + name: 'FederationControl', + + components: { + Actions, + ActionButton, + }, + + props: { + primary: { + type: Boolean, + default: false, + }, + email: { + type: String, + default: '', + }, + scope: { + type: String, + required: true, + }, + disabled: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + initialScope: this.scope, + federationScopes: Object.values(SCOPE_PROPERTY_ENUM).filter(({ name }) => !excludedScopes.includes(name)), + } + }, + + computed: { + scopeIcon() { + return SCOPE_PROPERTY_ENUM[this.scope].iconClass + }, + }, + + methods: { + async changeScope(scope) { + this.$emit('update:scope', scope) + + this.$nextTick(async() => { + if (this.primary) { + await this.updatePrimaryEmailScope() + } else { + await this.updateAdditionalEmailScope() + } + }) + }, + + async updatePrimaryEmailScope() { + try { + const responseData = await savePrimaryEmailScope(this.scope) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse('error', 'Unable to update federation scope of the primary email', e) + } + }, + + async updateAdditionalEmailScope() { + try { + const responseData = await saveAdditionalEmailScope(this.email, this.scope) + this.handleResponse(responseData.ocs?.meta?.status) + } catch (e) { + this.handleResponse('error', 'Unable to update federation scope of additional email', e) + } + }, + + handleResponse(status, errorMessage, error) { + if (status === 'ok') { + this.initialScope = this.scope + } else { + this.$emit('update:scope', this.initialScope) + showError(t('settings', errorMessage)) + this.logger.error(errorMessage, error) + } + }, + }, +} +</script> + +<style lang="scss" scoped> + .actions-federation { + opacity: 0.4 !important; + + &:hover { + opacity: 0.8 !important; + } + } + + .forced-active { + background-color: var(--color-primary-light) !important; + box-shadow: inset 2px 0 var(--color-primary) !important; + } + + .forced-action { + &::v-deep p { + width: 150px !important; + padding: 8px 0 !important; + color: var(--color-main-text) !important; + font-size: 12.8px !important; + line-height: 1.5em !important; + } + } +</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/EmailSection/HeaderBar.vue new file mode 100644 index 00000000000..7d2b1ab76b6 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/EmailSection/HeaderBar.vue @@ -0,0 +1,94 @@ +<!-- + - @copyright 2021, Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @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> + <h3> + <label for="email"> + {{ t('settings', 'Email') }} + </label> + + <FederationControl + class="federation-control" + :primary="true" + :scope.sync="localScope" + @update:scope="onScopeChange" /> + + <AddButton v-if="canEditEmails" + class="add-button" + :disabled="!isValidForm" + @click.stop.prevent="addAdditionalEmail" /> + </h3> +</template> + +<script> +import FederationControl from './FederationControl' +import AddButton from './AddButton' + +export default { + name: 'HeaderBar', + + components: { + FederationControl, + AddButton, + }, + + props: { + canEditEmails: { + type: Boolean, + default: true, + }, + isValidForm: { + type: Boolean, + default: true, + }, + scope: { + type: String, + required: true, + }, + }, + + data() { + return { + localScope: this.scope, + } + }, + + methods: { + addAdditionalEmail() { + this.$emit('addAdditionalEmail') + }, + + onScopeChange(scope) { + this.$emit('update:scope', scope) + }, + }, +} +</script> + +<style lang="scss" scoped> + .federation-control { + margin: -12px 0 0 8px; + } + + .add-button { + margin: -12px 0 0 auto !important; + } +</style> diff --git a/apps/settings/src/main-personal-info.js b/apps/settings/src/main-personal-info.js new file mode 100644 index 00000000000..8edbd29669f --- /dev/null +++ b/apps/settings/src/main-personal-info.js @@ -0,0 +1,38 @@ +/** + * @copyright 2021, Christopher Ng <chrng8@gmail.com> + * + * @author Christopher Ng <chrng8@gmail.com> + * + * @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/>. + * + */ + +import Vue from 'vue' + +import logger from './logger' + +import EmailSection from './components/PersonalInfo/EmailSection/EmailSection' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = btoa(OC.requestToken) + +Vue.prototype.t = t +Vue.prototype.logger = logger + +const View = Vue.extend(EmailSection) +export default new View({ + el: '#vue-emailsection', +}) |