diff options
author | Christopher Ng <chrng8@gmail.com> | 2021-10-14 08:28:54 +0000 |
---|---|---|
committer | Christopher Ng <chrng8@gmail.com> | 2021-10-19 04:59:36 +0000 |
commit | 3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e (patch) | |
tree | 5519fb218db5daa3d0e16198f600d5646f7d0b1a /apps/settings/src | |
parent | 309354852f12ae88d5eef05d311d6ebcba8ee762 (diff) | |
download | nextcloud-server-3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e.tar.gz nextcloud-server-3be9d3ca8fca4fb743a4d2f2ffe44a45fa9ffa6e.zip |
Profile frontend
Signed-off-by: Christopher Ng <chrng8@gmail.com>
Signed-off-by: nextcloud-command <nextcloud-command@users.noreply.github.com>
Diffstat (limited to 'apps/settings/src')
29 files changed, 2162 insertions, 151 deletions
diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue b/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue new file mode 100644 index 00000000000..99c1902999c --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue @@ -0,0 +1,182 @@ +<!-- + - @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 class="biography"> + <textarea + id="biography" + :placeholder="t('settings', 'Your biography')" + :value="biography" + rows="8" + autocapitalize="none" + autocomplete="off" + autocorrect="off" + @input="onBiographyChange" /> + + <div class="biography__actions-container"> + <transition name="fade"> + <span v-if="showCheckmarkIcon" class="icon-checkmark" /> + <span v-else-if="showErrorIcon" class="icon-error" /> + </transition> + </div> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import debounce from 'debounce' + +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' + +export default { + name: 'Biography', + + props: { + biography: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + }, + + data() { + return { + initialBiography: this.biography, + localScope: this.scope, + showCheckmarkIcon: false, + showErrorIcon: false, + } + }, + + methods: { + onBiographyChange(e) { + this.$emit('update:biography', e.target.value) + this.debounceBiographyChange(e.target.value.trim()) + }, + + debounceBiographyChange: debounce(async function(biography) { + await this.updatePrimaryBiography(biography) + }, 500), + + async updatePrimaryBiography(biography) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, biography) + this.handleResponse({ + biography, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update biography'), + error: e, + }) + } + }, + + handleResponse({ biography, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialBiography = biography + emit('settings:biography:updated', biography) + this.showCheckmarkIcon = true + setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + } else { + showError(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> +.biography { + display: grid; + align-items: center; + + textarea { + resize: none; + grid-area: 1 / 1; + width: 100%; + margin: 3px 3px 3px 0; + padding: 7px 6px; + color: var(--color-main-text); + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background-color: var(--color-main-background); + font-family: var(--font-face); + cursor: text; + + &:hover { + border-color: var(--color-primary-element) !important; + outline: none !important; + } + } + + .biography__actions-container { + grid-area: 1 / 1; + justify-self: flex-end; + align-self: flex-end; + height: 30px; + + display: flex; + gap: 0 2px; + margin-right: 5px; + margin-bottom: 5px; + + .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, +.fade-leave-to { + opacity: 0; +} + +.fade-enter-active { + transition: opacity 200ms ease-out; +} + +.fade-leave-active { + transition: opacity 300ms ease-out; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue new file mode 100644 index 00000000000..794d01aa954 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue @@ -0,0 +1,81 @@ +<!-- + - @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> + <section> + <HeaderBar + :account-property="accountProperty" + label-for="biography" + :scope.sync="primaryBiography.scope" /> + + <Biography + :biography.sync="primaryBiography.value" + :scope.sync="primaryBiography.scope" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Biography from './Biography' +import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' + +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { biographyMap: { primaryBiography } } = loadState('settings', 'personalInfoParameters', {}) +const { profileConfig: { biography: { visibility } } } = loadState('settings', 'profileParameters', {}) + +export default { + name: 'BiographySection', + + components: { + Biography, + HeaderBar, + VisibilityDropdown, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, + primaryBiography, + visibility, + } + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue index c751d616a89..584f8578337 100644 --- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue +++ b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue @@ -17,21 +17,19 @@ - - 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 class="displayname"> <input id="displayname" - ref="displayName" type="text" - name="displayname" :placeholder="t('settings', 'Your full name')" :value="displayName" autocapitalize="none" autocomplete="on" autocorrect="off" - required @input="onDisplayNameChange"> <div class="displayname__actions-container"> @@ -45,10 +43,12 @@ <script> import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' import debounce from 'debounce' -import { savePrimaryDisplayName } from '../../../service/PersonalInfo/DisplayNameService' -import { validateDisplayName } from '../../../utils/validate' +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' +import { validateStringInput } from '../../../utils/validate' // TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating @@ -82,21 +82,21 @@ export default { }, debounceDisplayNameChange: debounce(async function(displayName) { - if (validateDisplayName(displayName)) { + if (validateStringInput(displayName)) { await this.updatePrimaryDisplayName(displayName) } }, 500), async updatePrimaryDisplayName(displayName) { try { - const responseData = await savePrimaryDisplayName(displayName) + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayName) this.handleResponse({ displayName, status: responseData.ocs?.meta?.status, }) } catch (e) { this.handleResponse({ - errorMessage: 'Unable to update full name', + errorMessage: t('settings', 'Unable to update full name'), error: e, }) } @@ -106,10 +106,11 @@ export default { if (status === 'ok') { // Ensure that local state reflects server state this.initialDisplayName = displayName + emit('settings:display-name:updated', displayName) this.showCheckmarkIcon = true setTimeout(() => { this.showCheckmarkIcon = false }, 2000) } else { - showError(t('settings', errorMessage)) + showError(errorMessage) this.logger.error(errorMessage, error) this.showErrorIcon = true setTimeout(() => { this.showErrorIcon = false }, 2000) diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue index 8cf65c8381f..b46aea72856 100644 --- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue +++ b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue @@ -17,6 +17,7 @@ - - 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> @@ -26,13 +27,17 @@ label-for="displayname" :is-editable="displayNameChangeSupported" :is-valid-section="isValidSection" - :handle-scope-change="savePrimaryDisplayNameScope" :scope.sync="primaryDisplayName.scope" /> <template v-if="displayNameChangeSupported"> <DisplayName :display-name.sync="primaryDisplayName.value" :scope.sync="primaryDisplayName.scope" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> </template> <span v-else> @@ -46,13 +51,14 @@ import { loadState } from '@nextcloud/initial-state' import DisplayName from './DisplayName' import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryDisplayNameScope } from '../../../service/PersonalInfo/DisplayNameService' -import { validateDisplayName } from '../../../utils/validate' +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import { validateStringInput } from '../../../utils/validate' -const { displayNames: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {}) +const { displayNameMap: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {}) const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) +const { profileConfig: { displayname: { visibility } } } = loadState('settings', 'profileParameters', {}) export default { name: 'DisplayNameSection', @@ -60,20 +66,22 @@ export default { components: { DisplayName, HeaderBar, + VisibilityDropdown, }, data() { return { accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayNameChangeSupported, primaryDisplayName, - savePrimaryDisplayNameScope, + visibility, } }, computed: { isValidSection() { - return validateDisplayName(this.primaryDisplayName.value) + return validateStringInput(this.primaryDisplayName.value) }, }, } diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue index 08f91e6887c..3cf380842a1 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue @@ -17,22 +17,21 @@ - - 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"> <input - id="email" + :id="inputId" ref="email" type="email" - :name="inputName" :placeholder="inputPlaceholder" :value="email" autocapitalize="none" autocomplete="on" autocorrect="off" - required @input="onEmailChange"> <div class="email__actions-container"> @@ -47,7 +46,7 @@ :additional="true" :additional-value="email" :disabled="federationDisabled" - :handle-scope-change="saveAdditionalEmailScope" + :handle-additional-scope-change="saveAdditionalEmailScope" :scope.sync="localScope" @update:scope="onScopeChange" /> </template> @@ -185,11 +184,11 @@ export default { return !this.initialEmail }, - inputName() { + inputId() { if (this.primary) { return 'email' } - return 'additionalEmail[]' + return `email-${this.index}` }, inputPlaceholder() { @@ -253,12 +252,12 @@ export default { } catch (e) { if (email === '') { this.handleResponse({ - errorMessage: 'Unable to delete primary email address', + errorMessage: t('settings', 'Unable to delete primary email address'), error: e, }) } else { this.handleResponse({ - errorMessage: 'Unable to update primary email address', + errorMessage: t('settings', 'Unable to update primary email address'), error: e, }) } @@ -274,7 +273,7 @@ export default { }) } catch (e) { this.handleResponse({ - errorMessage: 'Unable to add additional email address', + errorMessage: t('settings', 'Unable to add additional email address'), error: e, }) } @@ -305,7 +304,7 @@ export default { }) } catch (e) { this.handleResponse({ - errorMessage: 'Unable to update additional email address', + errorMessage: t('settings', 'Unable to update additional email address'), error: e, }) } @@ -317,7 +316,7 @@ export default { this.handleDeleteAdditionalEmail(responseData.ocs?.meta?.status) } catch (e) { this.handleResponse({ - errorMessage: 'Unable to delete additional email address', + errorMessage: t('settings', 'Unable to delete additional email address'), error: e, }) } @@ -328,7 +327,7 @@ export default { this.$emit('delete-additional-email') } else { this.handleResponse({ - errorMessage: 'Unable to delete additional email address', + errorMessage: t('settings', 'Unable to delete additional email address'), }) } }, @@ -344,7 +343,7 @@ export default { this.showCheckmarkIcon = true setTimeout(() => { this.showCheckmarkIcon = false }, 2000) } else { - showError(t('settings', errorMessage)) + showError(errorMessage) this.logger.error(errorMessage, error) this.showErrorIcon = true setTimeout(() => { this.showErrorIcon = false }, 2000) diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue index 884d44f14c0..2a9aefd4162 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue @@ -17,6 +17,7 @@ - - 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> @@ -39,20 +40,30 @@ :active-notification-email.sync="notificationEmail" @update:email="onUpdateEmail" @update:notification-email="onUpdateNotificationEmail" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> </template> + <span v-else> {{ primaryEmail.value || t('settings', 'No email address set') }} </span> - <Email v-for="(additionalEmail, index) in additionalEmails" - :key="index" - :index="index" - :scope.sync="additionalEmail.scope" - :email.sync="additionalEmail.value" - :local-verification-state="parseInt(additionalEmail.locallyVerified, 10)" - :active-notification-email.sync="notificationEmail" - @update:email="onUpdateEmail" - @update:notification-email="onUpdateNotificationEmail" - @delete-additional-email="onDeleteAdditionalEmail(index)" /> + + <template v-if="additionalEmails.length"> + <em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em> + <Email v-for="(additionalEmail, index) in additionalEmails" + :key="index" + :index="index" + :scope.sync="additionalEmail.scope" + :email.sync="additionalEmail.value" + :local-verification-state="parseInt(additionalEmail.locallyVerified, 10)" + :active-notification-email.sync="notificationEmail" + @update:email="onUpdateEmail" + @update:notification-email="onUpdateNotificationEmail" + @delete-additional-email="onDeleteAdditionalEmail(index)" /> + </template> </section> </template> @@ -62,13 +73,15 @@ import { showError } from '@nextcloud/dialogs' import Email from './Email' import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' -import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants' +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants' import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService' import { validateEmail } from '../../../utils/validate' -const { emails: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {}) +const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {}) const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) +const { profileConfig: { email: { visibility } } } = loadState('settings', 'profileParameters', {}) export default { name: 'EmailSection', @@ -76,16 +89,19 @@ export default { components: { HeaderBar, Email, + VisibilityDropdown, }, data() { return { accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.EMAIL, additionalEmails, displayNameChangeSupported, primaryEmail, savePrimaryEmailScope, notificationEmail, + visibility, } }, @@ -141,7 +157,11 @@ export default { const responseData = await savePrimaryEmail(this.primaryEmailValue) this.handleResponse(responseData.ocs?.meta?.status) } catch (e) { - this.handleResponse('error', 'Unable to update primary email address', e) + this.handleResponse( + 'error', + t('settings', 'Unable to update primary email address'), + e + ) } }, @@ -150,7 +170,11 @@ export default { const responseData = await removeAdditionalEmail(this.firstAdditionalEmail) this.handleDeleteFirstAdditionalEmail(responseData.ocs?.meta?.status) } catch (e) { - this.handleResponse('error', 'Unable to delete additional email address', e) + this.handleResponse( + 'error', + t('settings', 'Unable to delete additional email address'), + e + ) } }, @@ -158,13 +182,17 @@ export default { if (status === 'ok') { this.$delete(this.additionalEmails, 0) } else { - this.handleResponse('error', 'Unable to delete additional email address', {}) + this.handleResponse( + 'error', + t('settings', 'Unable to delete additional email address'), + {} + ) } }, handleResponse(status, errorMessage, error) { if (status !== 'ok') { - showError(t('settings', errorMessage)) + showError(errorMessage) this.logger.error(errorMessage, error) } }, @@ -179,5 +207,10 @@ section { &::v-deep button:disabled { cursor: default; } + + .additional-emails-label { + display: block; + margin-top: 16px; + } } </style> diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue new file mode 100644 index 00000000000..4818fa9c6ab --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue @@ -0,0 +1,175 @@ +<!-- + - @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 class="headline"> + <input + id="headline" + type="text" + :placeholder="t('settings', 'Your headline')" + :value="headline" + autocapitalize="none" + autocomplete="on" + autocorrect="off" + @input="onHeadlineChange"> + + <div class="headline__actions-container"> + <transition name="fade"> + <span v-if="showCheckmarkIcon" class="icon-checkmark" /> + <span v-else-if="showErrorIcon" class="icon-error" /> + </transition> + </div> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import debounce from 'debounce' + +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' + +export default { + name: 'Headline', + + props: { + headline: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + }, + + data() { + return { + initialHeadline: this.headline, + localScope: this.scope, + showCheckmarkIcon: false, + showErrorIcon: false, + } + }, + + methods: { + onHeadlineChange(e) { + this.$emit('update:headline', e.target.value) + this.debounceHeadlineChange(e.target.value.trim()) + }, + + debounceHeadlineChange: debounce(async function(headline) { + await this.updatePrimaryHeadline(headline) + }, 500), + + async updatePrimaryHeadline(headline) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.HEADLINE, headline) + this.handleResponse({ + headline, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update headline'), + error: e, + }) + } + }, + + handleResponse({ headline, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialHeadline = headline + emit('settings:headline:updated', headline) + this.showCheckmarkIcon = true + setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + } else { + showError(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> +.headline { + display: grid; + align-items: center; + + input { + grid-area: 1 / 1; + width: 100%; + height: 34px; + margin: 3px 3px 3px 0; + padding: 7px 6px; + color: var(--color-main-text); + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background-color: var(--color-main-background); + font-family: var(--font-face); + cursor: text; + } + + .headline__actions-container { + grid-area: 1 / 1; + justify-self: flex-end; + height: 30px; + + display: flex; + gap: 0 2px; + margin-right: 5px; + + .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, +.fade-leave-to { + opacity: 0; +} + +.fade-enter-active { + transition: opacity 200ms ease-out; +} + +.fade-leave-active { + transition: opacity 300ms ease-out; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue new file mode 100644 index 00000000000..96d5ade1ce6 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue @@ -0,0 +1,81 @@ +<!-- + - @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> + <section> + <HeaderBar + :account-property="accountProperty" + label-for="headline" + :scope.sync="primaryHeadline.scope" /> + + <Headline + :headline.sync="primaryHeadline.value" + :scope.sync="primaryHeadline.scope" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Headline from './Headline' +import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' + +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { headlineMap: { primaryHeadline } } = loadState('settings', 'personalInfoParameters', {}) +const { profileConfig: { headline: { visibility } } } = loadState('settings', 'profileParameters', {}) + +export default { + name: 'HeadlineSection', + + components: { + Headline, + HeaderBar, + VisibilityDropdown, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.HEADLINE, + primaryHeadline, + visibility, + } + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue index fbd87c276ff..14fe7360148 100644 --- a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue +++ b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue @@ -17,16 +17,14 @@ - - 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 class="language"> <select id="language" - ref="language" - name="language" :placeholder="t('settings', 'Language')" - required @change="onLanguageChange"> <option v-for="commonLanguage in commonLanguages" :key="commonLanguage.code" @@ -57,7 +55,8 @@ <script> import { showError } from '@nextcloud/dialogs' -import { saveLanguage } from '../../../service/PersonalInfo/LanguageService' +import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' import { validateLanguage } from '../../../utils/validate' export default { @@ -105,7 +104,7 @@ export default { async updateLanguage(language) { try { - const responseData = await saveLanguage(language.code) + const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LANGUAGE, language.code) this.handleResponse({ language, status: responseData.ocs?.meta?.status, @@ -113,7 +112,7 @@ export default { this.reloadPage() } catch (e) { this.handleResponse({ - errorMessage: 'Unable to update language', + errorMessage: t('settings', 'Unable to update language'), error: e, }) } @@ -131,7 +130,7 @@ export default { // Ensure that local state reflects server state this.initialLanguage = language } else { - showError(t('settings', errorMessage)) + showError(errorMessage) this.logger.error(errorMessage, error) } }, diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue index 8be4c05ccca..8c53081fb80 100644 --- a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue +++ b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue @@ -17,14 +17,14 @@ - - 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> <section> <HeaderBar :account-property="accountProperty" - label-for="language" - :is-valid-section="isValidSection" /> + label-for="language" /> <template v-if="isEditable"> <Language @@ -45,10 +45,9 @@ import { loadState } from '@nextcloud/initial-state' import Language from './Language' import HeaderBar from '../shared/HeaderBar' -import { SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' -import { validateLanguage } from '../../../utils/validate' +import { ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' -const { languages: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {}) +const { languageMap: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {}) export default { name: 'LanguageSection', @@ -60,7 +59,7 @@ export default { data() { return { - accountProperty: SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, + accountProperty: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, commonLanguages, otherLanguages, language: activeLanguage, @@ -71,10 +70,6 @@ export default { isEditable() { return Boolean(this.language) }, - - isValidSection() { - return validateLanguage(this.language) - }, }, } </script> diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue new file mode 100644 index 00000000000..98e1143dbbb --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue @@ -0,0 +1,175 @@ +<!-- + - @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 class="organisation"> + <input + id="organisation" + type="text" + :placeholder="t('settings', 'Your organisation')" + :value="organisation" + autocapitalize="none" + autocomplete="on" + autocorrect="off" + @input="onOrganisationChange"> + + <div class="organisation__actions-container"> + <transition name="fade"> + <span v-if="showCheckmarkIcon" class="icon-checkmark" /> + <span v-else-if="showErrorIcon" class="icon-error" /> + </transition> + </div> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import debounce from 'debounce' + +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' + +export default { + name: 'Organisation', + + props: { + organisation: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + }, + + data() { + return { + initialOrganisation: this.organisation, + localScope: this.scope, + showCheckmarkIcon: false, + showErrorIcon: false, + } + }, + + methods: { + onOrganisationChange(e) { + this.$emit('update:organisation', e.target.value) + this.debounceOrganisationChange(e.target.value.trim()) + }, + + debounceOrganisationChange: debounce(async function(organisation) { + await this.updatePrimaryOrganisation(organisation) + }, 500), + + async updatePrimaryOrganisation(organisation) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ORGANISATION, organisation) + this.handleResponse({ + organisation, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update organisation'), + error: e, + }) + } + }, + + handleResponse({ organisation, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialOrganisation = organisation + emit('settings:organisation:updated', organisation) + this.showCheckmarkIcon = true + setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + } else { + showError(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> +.organisation { + display: grid; + align-items: center; + + input { + grid-area: 1 / 1; + width: 100%; + height: 34px; + margin: 3px 3px 3px 0; + padding: 7px 6px; + color: var(--color-main-text); + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background-color: var(--color-main-background); + font-family: var(--font-face); + cursor: text; + } + + .organisation__actions-container { + grid-area: 1 / 1; + justify-self: flex-end; + height: 30px; + + display: flex; + gap: 0 2px; + margin-right: 5px; + + .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, +.fade-leave-to { + opacity: 0; +} + +.fade-enter-active { + transition: opacity 200ms ease-out; +} + +.fade-leave-active { + transition: opacity 300ms ease-out; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue new file mode 100644 index 00000000000..57515ad7496 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue @@ -0,0 +1,81 @@ +<!-- + - @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> + <section> + <HeaderBar + :account-property="accountProperty" + label-for="organisation" + :scope.sync="primaryOrganisation.scope" /> + + <Organisation + :organisation.sync="primaryOrganisation.value" + :scope.sync="primaryOrganisation.scope" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Organisation from './Organisation' +import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' + +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { organisationMap: { primaryOrganisation } } = loadState('settings', 'personalInfoParameters', {}) +const { profileConfig: { organisation: { visibility } } } = loadState('settings', 'profileParameters', {}) + +export default { + name: 'OrganisationSection', + + components: { + Organisation, + HeaderBar, + VisibilityDropdown, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.ORGANISATION, + primaryOrganisation, + visibility, + } + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue new file mode 100644 index 00000000000..fba5a883ab4 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue @@ -0,0 +1,101 @@ +<!-- + - @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 class="checkbox-container"> + <input + id="enable-profile" + class="checkbox" + type="checkbox" + :checked="profileEnabled" + @change="onEnableProfileChange"> + <label for="enable-profile"> + {{ t('settings', 'Enable Profile') }} + </label> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' + +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' +import { validateBoolean } from '../../../utils/validate' +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' + +export default { + name: 'ProfileCheckbox', + + props: { + profileEnabled: { + type: Boolean, + required: true, + }, + }, + + data() { + return { + initialProfileEnabled: this.profileEnabled, + } + }, + + methods: { + async onEnableProfileChange(e) { + const isEnabled = e.target.checked + this.$emit('update:profile-enabled', isEnabled) + + if (validateBoolean(isEnabled)) { + await this.updateEnableProfile(isEnabled) + } + }, + + async updateEnableProfile(isEnabled) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, isEnabled) + this.handleResponse({ + isEnabled, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update profile enabled state'), + error: e, + }) + } + }, + + handleResponse({ isEnabled, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialProfileEnabled = isEnabled + emit('settings:profile-enabled:updated', isEnabled) + } else { + showError(errorMessage) + this.logger.error(errorMessage, error) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +</style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue new file mode 100644 index 00000000000..0613352f300 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue @@ -0,0 +1,192 @@ +<!-- + - @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> + <a + class="preview-card" + :class="{ disabled }" + :href="profilePageLink"> + <Avatar + class="preview-card__avatar" + :user="userId" + :size="48" + :show-user-status="true" + :show-user-status-compact="false" + :disable-menu="true" + :disable-tooltip="true" + @click.native.prevent.stop="openStatusModal" /> + <div class="preview-card__header"> + <span>{{ displayName }}</span> + </div> + <div class="preview-card__footer"> + <span>{{ organisation }}</span> + </div> + </a> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import { generateUrl } from '@nextcloud/router' + +import Avatar from '@nextcloud/vue/dist/Components/Avatar' + +export default { + name: 'ProfilePreviewCard', + + components: { + Avatar, + }, + + props: { + organisation: { + type: String, + required: true, + }, + displayName: { + type: String, + required: true, + }, + profileEnabled: { + type: Boolean, + required: true, + }, + userId: { + type: String, + required: true, + }, + }, + + data() { + return { + } + }, + + computed: { + disabled() { + return !this.profileEnabled + }, + + profilePageLink() { + if (this.profileEnabled) { + return generateUrl('/u/{userId}', { userId: getCurrentUser().uid }) + } + // Since an anchor element is used rather than a button for better UX, + // this hack removes href if the profile is disabled so that disabling pointer-events is not needed to prevent a click from opening a page + // and to allow the hover event (which disabling pointer-events wouldn't allow) for styling + return null + }, + }, + + methods: { + }, +} +</script> + +<style lang="scss" scoped> +.preview-card { + display: flex; + flex-direction: column; + position: relative; + width: 290px; + height: 116px; + margin: 14px auto; + border-radius: var(--border-radius-large); + background-color: var(--color-main-background); + font-weight: bold; + box-shadow: 0 2px 9px var(--color-box-shadow); + + &:hover { + box-shadow: 0 2px 12px var(--color-box-shadow); + } + + &.disabled { + filter: grayscale(1); + opacity: 0.5; + cursor: default; + box-shadow: 0 0 3px var(--color-box-shadow); + + & *, + &::v-deep * { + cursor: default; + } + } + + &__avatar { + // Override Avatar component position to fix positioning on rerender + position: absolute !important; + top: 40px; + left: 18px; + z-index: 1; + + &:not(.avatardiv--unknown) { + box-shadow: 0 0 0 3px var(--color-main-background) !important; + } + } + + &__header { + position: relative !important; + width: auto !important; + height: 70px !important; + border-radius: var(--border-radius-large) var(--border-radius-large) 0 0 !important; + + span { + position: absolute; + bottom: 0; + left: 78px; + color: var(--color-primary-text); + font-size: 18px; + font-weight: bold; + margin-bottom: 8px; + } + } + + &__footer { + position: relative; + width: auto; + height: 46px; + + span { + position: absolute; + top: 0; + left: 78px; + color: var(--color-text-maxcontrast); + font-size: 14px; + font-weight: normal; + margin-top: 4px; + line-height: 1.3; + + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + @supports (-webkit-line-clamp: 2) { + overflow: hidden; + white-space: initial; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + } + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue new file mode 100644 index 00000000000..059233c9396 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue @@ -0,0 +1,105 @@ +<!-- + - @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> + <section> + <HeaderBar + :account-property="accountProperty" /> + + <ProfileCheckbox + :profile-enabled.sync="profileEnabled" /> + + <ProfilePreviewCard + :organisation="organisation" + :display-name="displayName" + :profile-enabled="profileEnabled" + :user-id="userId" /> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' + +import HeaderBar from '../shared/HeaderBar' +import ProfileCheckbox from './ProfileCheckbox' +import ProfilePreviewCard from './ProfilePreviewCard' + +import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { + organisationMap: { primaryOrganisation: { value: organisation } }, + displayNameMap: { primaryDisplayName: { value: displayName } }, + profileEnabled, + userId, +} = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'ProfileSection', + + components: { + HeaderBar, + ProfileCheckbox, + ProfilePreviewCard, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED, + organisation, + displayName, + profileEnabled, + userId, + } + }, + + mounted() { + subscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + subscribe('settings:organisation:updated', this.handleOrganisationUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + unsubscribe('settings:organisation:updated', this.handleOrganisationUpdate) + }, + + methods: { + handleDisplayNameUpdate(displayName) { + this.displayName = displayName + }, + + handleOrganisationUpdate(organisation) { + this.organisation = organisation + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue new file mode 100644 index 00000000000..c27bef347c6 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.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> + <section> + <HeaderBar + :account-property="heading" /> + + <VisibilityDropdown v-for="parameter in visibilityArray" + :key="parameter.id" + :param-id="parameter.id" + :display-id="parameter.displayId" + :show-display-id="true" + :visibility.sync="parameter.visibility" /> + + <em :class="{ disabled }">{{ t('settings', 'The more restrictive setting of either visibility or scope is respected on your Profile — For example, when visibility is set to "Show to everyone" and scope is set to "Private", "Private" will be respected') }}</em> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' + +import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' +import { ACCOUNT_PROPERTY_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { profileConfig } = loadState('settings', 'profileParameters', {}) +const { profileEnabled } = loadState('settings', 'personalInfoParameters', false) + +export default { + name: 'ProfileVisibilitySection', + + components: { + HeaderBar, + VisibilityDropdown, + }, + + data() { + return { + heading: PROFILE_READABLE_ENUM.PROFILE_VISIBILITY, + profileEnabled, + visibilityArray: Object.entries(profileConfig) + // Filter for profile parameters registered by apps in this section as visibility controls for the rest (account properties) are handled in their respective property sections + .filter(([paramId, { displayId, visibility }]) => !Object.values(ACCOUNT_PROPERTY_ENUM).includes(paramId)) + .map(([paramId, { displayId, visibility }]) => ({ id: paramId, displayId, visibility })), + } + }, + + computed: { + disabled() { + return !this.profileEnabled + }, + }, + + mounted() { + subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + }, + + methods: { + handleProfileEnabledUpdate(profileEnabled) { + this.profileEnabled = profileEnabled + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + em { + display: block; + margin-top: 16px; + + &.disabled { + filter: grayscale(1); + opacity: 0.5; + cursor: default; + pointer-events: none; + + & *, + &::v-deep * { + cursor: default; + pointer-events: none; + } + } + } + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue b/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue new file mode 100644 index 00000000000..c0e99038856 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue @@ -0,0 +1,175 @@ +<!-- + - @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 class="role"> + <input + id="role" + type="text" + :placeholder="t('settings', 'Your role')" + :value="role" + autocapitalize="none" + autocomplete="on" + autocorrect="off" + @input="onRoleChange"> + + <div class="role__actions-container"> + <transition name="fade"> + <span v-if="showCheckmarkIcon" class="icon-checkmark" /> + <span v-else-if="showErrorIcon" class="icon-error" /> + </transition> + </div> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { emit } from '@nextcloud/event-bus' +import debounce from 'debounce' + +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' + +export default { + name: 'Role', + + props: { + role: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + }, + + data() { + return { + initialRole: this.role, + localScope: this.scope, + showCheckmarkIcon: false, + showErrorIcon: false, + } + }, + + methods: { + onRoleChange(e) { + this.$emit('update:role', e.target.value) + this.debounceRoleChange(e.target.value.trim()) + }, + + debounceRoleChange: debounce(async function(role) { + await this.updatePrimaryRole(role) + }, 500), + + async updatePrimaryRole(role) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ROLE, role) + this.handleResponse({ + role, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update role'), + error: e, + }) + } + }, + + handleResponse({ role, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialRole = role + emit('settings:role:updated', role) + this.showCheckmarkIcon = true + setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + } else { + showError(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> +.role { + display: grid; + align-items: center; + + input { + grid-area: 1 / 1; + width: 100%; + height: 34px; + margin: 3px 3px 3px 0; + padding: 7px 6px; + color: var(--color-main-text); + border: 1px solid var(--color-border-dark); + border-radius: var(--border-radius); + background-color: var(--color-main-background); + font-family: var(--font-face); + cursor: text; + } + + .role__actions-container { + grid-area: 1 / 1; + justify-self: flex-end; + height: 30px; + + display: flex; + gap: 0 2px; + margin-right: 5px; + + .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, +.fade-leave-to { + opacity: 0; +} + +.fade-enter-active { + transition: opacity 200ms ease-out; +} + +.fade-leave-active { + transition: opacity 300ms ease-out; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue b/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue new file mode 100644 index 00000000000..62ddefece81 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue @@ -0,0 +1,81 @@ +<!-- + - @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> + <section> + <HeaderBar + :account-property="accountProperty" + label-for="role" + :scope.sync="primaryRole.scope" /> + + <Role + :role.sync="primaryRole.value" + :scope.sync="primaryRole.scope" /> + + <VisibilityDropdown + :param-id="accountPropertyId" + :display-id="accountProperty" + :visibility.sync="visibility" /> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Role from './Role' +import HeaderBar from '../shared/HeaderBar' +import VisibilityDropdown from '../shared/VisibilityDropdown' + +import { ACCOUNT_PROPERTY_ENUM, ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' + +const { roleMap: { primaryRole } } = loadState('settings', 'personalInfoParameters', {}) +const { profileConfig: { role: { visibility } } } = loadState('settings', 'profileParameters', {}) + +export default { + name: 'RoleSection', + + components: { + Role, + HeaderBar, + VisibilityDropdown, + }, + + data() { + return { + accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE, + accountPropertyId: ACCOUNT_PROPERTY_ENUM.ROLE, + primaryRole, + visibility, + } + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + &::v-deep button:disabled { + cursor: default; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/shared/AddButton.vue b/apps/settings/src/components/PersonalInfo/shared/AddButton.vue index 07fc5bbcdbd..1fe7a532419 100644 --- a/apps/settings/src/components/PersonalInfo/shared/AddButton.vue +++ b/apps/settings/src/components/PersonalInfo/shared/AddButton.vue @@ -17,6 +17,7 @@ - - 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> diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue index b94c3e38760..76322f5c7db 100644 --- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue +++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue @@ -17,6 +17,7 @@ - - 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> @@ -25,27 +26,28 @@ :aria-label="ariaLabel" :default-icon="scopeIcon" :disabled="disabled"> - <ActionButton v-for="federationScope in federationScopes" + <FederationControlAction v-for="federationScope in federationScopes" :key="federationScope.name" - :aria-label="federationScope.tooltip" - class="federation-actions__btn" - :class="{ 'federation-actions__btn--active': scope === federationScope.name }" - :close-after-click="true" - :icon="federationScope.iconClass" - :title="federationScope.displayName" - @click.stop.prevent="changeScope(federationScope.name)"> - {{ federationScope.tooltip }} - </ActionButton> + :active-scope="scope" + :display-name="federationScope.displayName" + :handle-scope-change="changeScope" + :icon-class="federationScope.iconClass" + :is-supported-scope="supportedScopes.includes(federationScope.name)" + :name="federationScope.name" + :tooltip-disabled="federationScope.tooltipDisabled" + :tooltip="federationScope.tooltip" /> </Actions> </template> <script> import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import { loadState } from '@nextcloud/initial-state' import { showError } from '@nextcloud/dialogs' -import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import FederationControlAction from './FederationControlAction' + +import { ACCOUNT_PROPERTY_READABLE_ENUM, PROPERTY_READABLE_KEYS_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, SCOPE_ENUM, SCOPE_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService' const { lookupServerUploadEnabled } = loadState('settings', 'accountParameters', {}) @@ -54,7 +56,7 @@ export default { components: { Actions, - ActionButton, + FederationControlAction, }, props: { @@ -75,9 +77,9 @@ export default { type: Boolean, default: false, }, - handleScopeChange: { + handleAdditionalScopeChange: { type: Function, - required: true, + default: null, }, scope: { type: String, @@ -94,17 +96,17 @@ export default { computed: { ariaLabel() { - return t('settings', 'Change privacy level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase }) - }, - - federationScopes() { - return Object.values(SCOPE_PROPERTY_ENUM).filter(({ name }) => this.supportedScopes.includes(name)) + return t('settings', 'Change scope level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase }) }, scopeIcon() { return SCOPE_PROPERTY_ENUM[this.scope].iconClass }, + federationScopes() { + return Object.values(SCOPE_PROPERTY_ENUM) + }, + supportedScopes() { if (lookupServerUploadEnabled) { return [ @@ -131,7 +133,7 @@ export default { async updatePrimaryScope(scope) { try { - const responseData = await this.handleScopeChange(scope) + const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.accountProperty], scope) this.handleResponse({ scope, status: responseData.ocs?.meta?.status, @@ -146,7 +148,7 @@ export default { async updateAdditionalScope(scope) { try { - const responseData = await this.handleScopeChange(this.additionalValue, scope) + const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope) this.handleResponse({ scope, status: responseData.ocs?.meta?.status, @@ -192,19 +194,4 @@ export default { min-width: 30px !important; } } - - .federation-actions__btn { - &::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; - } - } - - .federation-actions__btn--active { - background-color: var(--color-primary-light) !important; - box-shadow: inset 2px 0 var(--color-primary) !important; - } </style> diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue new file mode 100644 index 00000000000..cba189a844e --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue @@ -0,0 +1,105 @@ +<!-- + - @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> + <ActionButton + :aria-label="isSupportedScope ? tooltip : tooltipDisabled" + class="federation-actions__btn" + :class="{ 'federation-actions__btn--active': activeScope === name }" + :close-after-click="true" + :disabled="!isSupportedScope" + :icon="iconClass" + :title="displayName" + @click.stop.prevent="updateScope"> + {{ isSupportedScope ? tooltip : tooltipDisabled }} + </ActionButton> +</template> + +<script> +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' + +export default { + name: 'FederationControlAction', + + components: { + ActionButton, + }, + + props: { + activeScope: { + type: String, + required: true, + }, + displayName: { + type: String, + required: true, + }, + handleScopeChange: { + type: Function, + default: () => {}, + }, + iconClass: { + type: String, + required: true, + }, + isSupportedScope: { + type: Boolean, + required: true, + }, + name: { + type: String, + required: true, + }, + tooltipDisabled: { + type: String, + default: '', + }, + tooltip: { + type: String, + required: true, + }, + }, + + methods: { + updateScope() { + this.handleScopeChange(this.name) + }, + }, +} +</script> + +<style lang="scss" scoped> + .federation-actions__btn { + &::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; + } + } + + .federation-actions__btn--active { + background-color: var(--color-primary-light) !important; + box-shadow: inset 2px 0 var(--color-primary) !important; + } +</style> diff --git a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue index 5388984494d..1e6ab01dbbb 100644 --- a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue +++ b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue @@ -17,21 +17,21 @@ - - 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 - :class="{ 'setting-property': isSettingProperty }"> + :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }"> <label :for="labelFor"> <!-- Already translated as required by prop validator --> {{ accountProperty }} </label> - <template v-if="scope && handleScopeChange"> + <template v-if="scope"> <FederationControl class="federation-control" :account-property="accountProperty" - :handle-scope-change="handleScopeChange" :scope.sync="localScope" @update:scope="onScopeChange" /> </template> @@ -49,7 +49,7 @@ import AddButton from './AddButton' import FederationControl from './FederationControl' -import { ACCOUNT_PROPERTY_READABLE_ENUM, SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import { ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' export default { name: 'HeaderBar', @@ -63,11 +63,7 @@ export default { accountProperty: { type: String, required: true, - validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(value), - }, - handleScopeChange: { - type: Function, - default: null, + 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, }, isEditable: { type: Boolean, @@ -83,7 +79,7 @@ export default { }, labelFor: { type: String, - required: true, + default: '', }, scope: { type: String, @@ -98,8 +94,12 @@ export default { }, computed: { + isProfileProperty() { + return this.accountProperty === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED + }, + isSettingProperty() { - return Object.values(SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty) + return Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty) }, }, @@ -123,10 +123,14 @@ export default { font-size: 16px; color: var(--color-text-light); - &.setting-property { + &.profile-property { height: 38px; } + &.setting-property { + height: 32px; + } + label { cursor: pointer; } diff --git a/apps/settings/src/components/PersonalInfo/shared/VisibilityDropdown.vue b/apps/settings/src/components/PersonalInfo/shared/VisibilityDropdown.vue new file mode 100644 index 00000000000..23c02d360bc --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/shared/VisibilityDropdown.vue @@ -0,0 +1,178 @@ +<!-- + - @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 + class="visibility-container" + :class="{ disabled }"> + <label :for="inputId"> + {{ showDisplayId ? t('settings', '{displayId} visibility', { displayId }) : t('settings', 'Visibility on Profile') }} + </label> + <Multiselect + :id="inputId" + :options="visibilityOptions" + track-by="name" + label="label" + :value="visibilityObject" + @change="onVisibilityChange" /> + </div> +</template> + +<script> +import { showError } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { subscribe, unsubscribe } from '@nextcloud/event-bus' + +import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' + +import { saveProfileParameterVisibility } from '../../../service/ProfileService' +import { validateStringInput } from '../../../utils/validate' +import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants' + +const { profileConfig } = loadState('settings', 'profileParameters', {}) +const { profileEnabled } = loadState('settings', 'personalInfoParameters', false) + +export default { + name: 'VisibilityDropdown', + + components: { + Multiselect, + }, + + props: { + paramId: { + type: String, + required: true, + }, + displayId: { + type: String, + required: true, + }, + showDisplayId: { + type: Boolean, + default: false, + }, + }, + + data() { + return { + initialVisibility: profileConfig[this.paramId].visibility, + profileEnabled, + visibility: profileConfig[this.paramId].visibility, + } + }, + + computed: { + disabled() { + return !this.profileEnabled + }, + + inputId() { + return `profile-visibility-${this.paramId}` + }, + + visibilityObject() { + return VISIBILITY_PROPERTY_ENUM[this.visibility] + }, + + visibilityOptions() { + return Object.values(VISIBILITY_PROPERTY_ENUM) + }, + }, + + mounted() { + subscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:profile-enabled:updated', this.handleProfileEnabledUpdate) + }, + + methods: { + async onVisibilityChange(visibilityObject) { + // This check is needed as the argument is null when selecting the same option + if (visibilityObject !== null) { + const { name: visibility } = visibilityObject + this.visibility = visibility + + if (validateStringInput(visibility)) { + await this.updateVisibility(visibility) + } + } + }, + + async updateVisibility(visibility) { + try { + const responseData = await saveProfileParameterVisibility(this.paramId, visibility) + this.handleResponse({ + visibility, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update visibility of {displayId}', { displayId: this.displayId }), + error: e, + }) + } + }, + + handleResponse({ visibility, status, errorMessage, error }) { + if (status === 'ok') { + // Ensure that local state reflects server state + this.initialVisibility = visibility + } else { + showError(errorMessage) + this.logger.error(errorMessage, error) + } + }, + + handleProfileEnabledUpdate(profileEnabled) { + this.profileEnabled = profileEnabled + }, + }, +} +</script> + +<style lang="scss" scoped> +.visibility-container { + margin-top: 16px; + display: grid; + + &.disabled { + filter: grayscale(1); + opacity: 0.5; + cursor: default; + pointer-events: none; + + & *, + &::v-deep * { + cursor: default; + pointer-events: none; + } + } + + label { + color: var(--color-text-lighter); + margin-bottom: 3px; + } +} +</style> diff --git a/apps/settings/src/constants/AccountPropertyConstants.js b/apps/settings/src/constants/AccountPropertyConstants.js index 95bf3a87dfb..6e39718f1c2 100644 --- a/apps/settings/src/constants/AccountPropertyConstants.js +++ b/apps/settings/src/constants/AccountPropertyConstants.js @@ -21,7 +21,7 @@ */ /* - * SYNC to be kept in sync with lib/public/Accounts/IAccountManager.php + * SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php` */ import { translate as t } from '@nextcloud/l10n' @@ -30,11 +30,16 @@ import { translate as t } from '@nextcloud/l10n' export const ACCOUNT_PROPERTY_ENUM = Object.freeze({ ADDRESS: 'address', AVATAR: 'avatar', + BIOGRAPHY: 'biography', DISPLAYNAME: 'displayname', - EMAIL: 'email', EMAIL_COLLECTION: 'additional_mail', + EMAIL: 'email', + HEADLINE: 'headline', NOTIFICATION_EMAIL: 'notify_email', + ORGANISATION: 'organisation', PHONE: 'phone', + PROFILE_ENABLED: 'profile_enabled', + ROLE: 'role', TWITTER: 'twitter', WEBSITE: 'website', }) @@ -43,28 +48,59 @@ export const ACCOUNT_PROPERTY_ENUM = Object.freeze({ export const ACCOUNT_PROPERTY_READABLE_ENUM = Object.freeze({ ADDRESS: t('settings', 'Address'), AVATAR: t('settings', 'Avatar'), + BIOGRAPHY: t('settings', 'About'), DISPLAYNAME: t('settings', 'Full name'), - EMAIL: t('settings', 'Email'), EMAIL_COLLECTION: t('settings', 'Additional email'), + EMAIL: t('settings', 'Email'), + HEADLINE: t('settings', 'Headline'), + ORGANISATION: t('settings', 'Organisation'), PHONE: t('settings', 'Phone number'), + PROFILE_ENABLED: t('settings', 'Profile'), + ROLE: t('settings', 'Role'), TWITTER: t('settings', 'Twitter'), WEBSITE: t('settings', 'Website'), }) -/** Enum of setting properties */ -export const SETTING_PROPERTY_ENUM = Object.freeze({ +/** Enum of profile specific sections to human readable names */ +export const PROFILE_READABLE_ENUM = Object.freeze({ + PROFILE_VISIBILITY: t('settings', 'Profile Visibility'), +}) + +/** Enum of readable account properties to account property keys used by the server */ +export const PROPERTY_READABLE_KEYS_ENUM = Object.freeze({ + [ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: ACCOUNT_PROPERTY_ENUM.ADDRESS, + [ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: ACCOUNT_PROPERTY_ENUM.AVATAR, + [ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, + [ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, + [ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: ACCOUNT_PROPERTY_ENUM.EMAIL_COLLECTION, + [ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: ACCOUNT_PROPERTY_ENUM.EMAIL, + [ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: ACCOUNT_PROPERTY_ENUM.HEADLINE, + [ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: ACCOUNT_PROPERTY_ENUM.ORGANISATION, + [ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: ACCOUNT_PROPERTY_ENUM.PHONE, + [ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, + [ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: ACCOUNT_PROPERTY_ENUM.ROLE, + [ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: ACCOUNT_PROPERTY_ENUM.TWITTER, + [ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: ACCOUNT_PROPERTY_ENUM.WEBSITE, +}) + +/** + * Enum of account setting properties + * + * *Account setting properties unlike account properties do not support scopes* + */ +export const ACCOUNT_SETTING_PROPERTY_ENUM = Object.freeze({ LANGUAGE: 'language', }) -/** Enum of setting properties to human readable setting properties */ -export const SETTING_PROPERTY_READABLE_ENUM = Object.freeze({ - LANGUAGE: 'Language', +/** Enum of account setting properties to human readable setting properties */ +export const ACCOUNT_SETTING_PROPERTY_READABLE_ENUM = Object.freeze({ + LANGUAGE: t('settings', 'Language'), }) /** Enum of scopes */ export const SCOPE_ENUM = Object.freeze({ - LOCAL: 'v2-local', PRIVATE: 'v2-private', + LOCAL: 'v2-local', FEDERATED: 'v2-federated', PUBLISHED: 'v2-published', }) @@ -73,10 +109,15 @@ export const SCOPE_ENUM = Object.freeze({ export const PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM = Object.freeze({ [ACCOUNT_PROPERTY_READABLE_ENUM.ADDRESS]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.AVATAR]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], + [ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME]: [SCOPE_ENUM.LOCAL], - [ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL], [ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL_COLLECTION]: [SCOPE_ENUM.LOCAL], + [ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL]: [SCOPE_ENUM.LOCAL], + [ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], + [ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.PHONE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], + [ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], + [ACCOUNT_PROPERTY_READABLE_ENUM.ROLE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.TWITTER]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], [ACCOUNT_PROPERTY_READABLE_ENUM.WEBSITE]: [SCOPE_ENUM.LOCAL, SCOPE_ENUM.PRIVATE], }) @@ -90,28 +131,32 @@ export const SCOPE_SUFFIX = 'Scope' * *Used for federation control* */ export const SCOPE_PROPERTY_ENUM = Object.freeze({ - [SCOPE_ENUM.LOCAL]: { - name: SCOPE_ENUM.LOCAL, - displayName: t('settings', 'Local'), - tooltip: t('settings', 'Only visible to people on this instance and guests'), - iconClass: 'icon-password', - }, [SCOPE_ENUM.PRIVATE]: { name: SCOPE_ENUM.PRIVATE, displayName: t('settings', 'Private'), tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'), + tooltipDisabled: t('settings', 'Unavailable as this property is required for core functionality including file sharing and calendar invitations\n\nOnly visible to people matched via phone number integration through Talk on mobile'), iconClass: 'icon-phone', }, + [SCOPE_ENUM.LOCAL]: { + name: SCOPE_ENUM.LOCAL, + displayName: t('settings', 'Local'), + tooltip: t('settings', 'Only visible to people on this instance and guests'), + // tooltipDisabled is not required here as this scope is supported by all account properties + iconClass: 'icon-password', + }, [SCOPE_ENUM.FEDERATED]: { name: SCOPE_ENUM.FEDERATED, displayName: t('settings', 'Federated'), tooltip: t('settings', 'Only synchronize to trusted servers'), + tooltipDisabled: t('settings', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nOnly synchronize to trusted servers'), iconClass: 'icon-contacts-dark', }, [SCOPE_ENUM.PUBLISHED]: { name: SCOPE_ENUM.PUBLISHED, displayName: t('settings', 'Published'), tooltip: t('settings', 'Synchronize to trusted servers and the global and public address book'), + tooltipDisabled: t('settings', 'Unavailable as publishing user specific data to the lookup server is not allowed, contact your system administrator if you have any questions\n\nSynchronize to trusted servers and the global and public address book'), iconClass: 'icon-link', }, }) diff --git a/apps/settings/src/constants/ProfileConstants.js b/apps/settings/src/constants/ProfileConstants.js new file mode 100644 index 00000000000..7c236c9b3ab --- /dev/null +++ b/apps/settings/src/constants/ProfileConstants.js @@ -0,0 +1,50 @@ +/** + * @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/>. + * + */ + +/* + * SYNC to be kept in sync with `core/Db/ProfileConfig.php` + */ + +/** Enum of profile visibility constants */ +export const VISIBILITY_ENUM = Object.freeze({ + SHOW: 'show', + SHOW_USERS_ONLY: 'show_users_only', + HIDE: 'hide', +}) + +/** + * Enum of profile visibility constants to properties + */ +export const VISIBILITY_PROPERTY_ENUM = Object.freeze({ + [VISIBILITY_ENUM.SHOW]: { + name: VISIBILITY_ENUM.SHOW, + label: t('settings', 'Show to everyone'), + }, + [VISIBILITY_ENUM.SHOW_USERS_ONLY]: { + name: VISIBILITY_ENUM.SHOW_USERS_ONLY, + label: t('settings', 'Show to logged in users only'), + }, + [VISIBILITY_ENUM.HIDE]: { + name: VISIBILITY_ENUM.HIDE, + label: t('settings', 'Hide'), + }, +}) diff --git a/apps/settings/src/main-personal-info.js b/apps/settings/src/main-personal-info.js index 78de03cf7cf..95169aee7e6 100644 --- a/apps/settings/src/main-personal-info.js +++ b/apps/settings/src/main-personal-info.js @@ -22,6 +22,7 @@ import Vue from 'vue' import { getRequestToken } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' import { translate as t } from '@nextcloud/l10n' import '@nextcloud/dialogs/styles/toast.scss' @@ -30,6 +31,13 @@ import logger from './logger' import DisplayNameSection from './components/PersonalInfo/DisplayNameSection/DisplayNameSection' import EmailSection from './components/PersonalInfo/EmailSection/EmailSection' import LanguageSection from './components/PersonalInfo/LanguageSection/LanguageSection' +import ProfileSection from './components/PersonalInfo/ProfileSection/ProfileSection' +import OrganisationSection from './components/PersonalInfo/OrganisationSection/OrganisationSection' +import RoleSection from './components/PersonalInfo/RoleSection/RoleSection' +import HeadlineSection from './components/PersonalInfo/HeadlineSection/HeadlineSection' +import BiographySection from './components/PersonalInfo/BiographySection/BiographySection' +import ProfileVisibilitySection from './components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection' +import VisibilityDropdown from './components/PersonalInfo/shared/VisibilityDropdown' __webpack_nonce__ = btoa(getRequestToken()) @@ -45,7 +53,40 @@ Vue.mixin({ const DisplayNameView = Vue.extend(DisplayNameSection) const EmailView = Vue.extend(EmailSection) const LanguageView = Vue.extend(LanguageSection) +const ProfileView = Vue.extend(ProfileSection) +const OrganisationView = Vue.extend(OrganisationSection) +const RoleView = Vue.extend(RoleSection) +const HeadlineView = Vue.extend(HeadlineSection) +const BiographyView = Vue.extend(BiographySection) +const ProfileVisibilityView = Vue.extend(ProfileVisibilitySection) +const VisibilityDropdownView = Vue.extend(VisibilityDropdown) -new DisplayNameView().$mount('#vue-displaynamesection') -new EmailView().$mount('#vue-emailsection') -new LanguageView().$mount('#vue-languagesection') +new DisplayNameView().$mount('#vue-displayname-section') +new EmailView().$mount('#vue-email-section') +new LanguageView().$mount('#vue-language-section') +new ProfileView().$mount('#vue-profile-section') +new OrganisationView().$mount('#vue-organisation-section') +new RoleView().$mount('#vue-role-section') +new HeadlineView().$mount('#vue-headline-section') +new BiographyView().$mount('#vue-biography-section') +new ProfileVisibilityView().$mount('#vue-profile-visibility-section') + +// Profile visibility dropdowns +const { profileConfig } = loadState('settings', 'profileParameters', {}) +const visibilityDropdownParamIds = [ + 'avatar', + 'phone', + 'address', + 'website', + 'twitter', +] + +for (const paramId of visibilityDropdownParamIds) { + const { displayId } = profileConfig[paramId] + new VisibilityDropdownView({ + propsData: { + paramId, + displayId, + }, + }).$mount(`#vue-profile-visibility-${paramId}`) +} diff --git a/apps/settings/src/service/PersonalInfo/DisplayNameService.js b/apps/settings/src/service/PersonalInfo/PersonalInfoService.js index f0e98c015de..e725300675c 100644 --- a/apps/settings/src/service/PersonalInfo/DisplayNameService.js +++ b/apps/settings/src/service/PersonalInfo/PersonalInfoService.js @@ -25,42 +25,50 @@ import { getCurrentUser } from '@nextcloud/auth' import { generateOcsUrl } from '@nextcloud/router' import confirmPassword from '@nextcloud/password-confirmation' -import { ACCOUNT_PROPERTY_ENUM, SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants' +import { SCOPE_SUFFIX } from '../../constants/AccountPropertyConstants' /** - * Save the primary display name of the user + * Save the primary account property value for the user * - * @param {string} displayName the primary display name + * @param {string} accountProperty the account property + * @param {string|boolean} value the primary value * @returns {object} */ -export const savePrimaryDisplayName = async(displayName) => { +export const savePrimaryAccountProperty = async(accountProperty, value) => { + // TODO allow boolean values on backend route handler + // Convert boolean to string for compatibility + if (typeof value === 'boolean') { + value = value ? '1' : '0' + } + const userId = getCurrentUser().uid const url = generateOcsUrl('cloud/users/{userId}', { userId }) await confirmPassword() const res = await axios.put(url, { - key: ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, - value: displayName, + key: accountProperty, + value, }) return res.data } /** - * Save the federation scope for the primary display name of the user + * Save the federation scope of the primary account property for the user * + * @param {string} accountProperty the account property * @param {string} scope the federation scope * @returns {object} */ -export const savePrimaryDisplayNameScope = async(scope) => { +export const savePrimaryAccountPropertyScope = async(accountProperty, scope) => { const userId = getCurrentUser().uid const url = generateOcsUrl('cloud/users/{userId}', { userId }) await confirmPassword() const res = await axios.put(url, { - key: `${ACCOUNT_PROPERTY_ENUM.DISPLAYNAME}${SCOPE_SUFFIX}`, + key: `${accountProperty}${SCOPE_SUFFIX}`, value: scope, }) diff --git a/apps/settings/src/service/PersonalInfo/LanguageService.js b/apps/settings/src/service/ProfileService.js index 56868f208f7..8748db46152 100644 --- a/apps/settings/src/service/PersonalInfo/LanguageService.js +++ b/apps/settings/src/service/ProfileService.js @@ -1,5 +1,5 @@ /** - * @copyright 2021, Christopher Ng <chrng8@gmail.com> + * @copyright 2021 Christopher Ng <chrng8@gmail.com> * * @author Christopher Ng <chrng8@gmail.com> * @@ -12,7 +12,7 @@ * * 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 + * 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 @@ -25,23 +25,22 @@ import { getCurrentUser } from '@nextcloud/auth' import { generateOcsUrl } from '@nextcloud/router' import confirmPassword from '@nextcloud/password-confirmation' -import { SETTING_PROPERTY_ENUM } from '../../constants/AccountPropertyConstants' - /** - * Save the language of the user + * Save the visibility of the profile parameter * - * @param {string} languageCode the language code + * @param {string} paramId the profile parameter ID + * @param {string} visibility the visibility * @returns {object} */ -export const saveLanguage = async(languageCode) => { +export const saveProfileParameterVisibility = async(paramId, visibility) => { const userId = getCurrentUser().uid - const url = generateOcsUrl('cloud/users/{userId}', { userId }) + const url = generateOcsUrl('/profile/{userId}', { userId }) await confirmPassword() const res = await axios.put(url, { - key: SETTING_PROPERTY_ENUM.LANGUAGE, - value: languageCode, + paramId, + visibility, }) return res.data diff --git a/apps/settings/src/utils/validate.js b/apps/settings/src/utils/validate.js index a0edb87dfa8..f7371301909 100644 --- a/apps/settings/src/utils/validate.js +++ b/apps/settings/src/utils/validate.js @@ -29,12 +29,14 @@ import { VALIDATE_EMAIL_REGEX } from '../constants/AccountPropertyConstants' /** - * Validate the display name input + * Validate the string input + * + * *Generic validator just to check that input is not an empty string* * * @param {string} input the input * @returns {boolean} */ -export function validateDisplayName(input) { +export function validateStringInput(input) { return input !== '' } @@ -67,3 +69,13 @@ export function validateLanguage(input) { && input.name !== '' && input.name !== undefined } + +/** + * Validate boolean input + * + * @param {boolean} input the input + * @returns {boolean} + */ +export function validateBoolean(input) { + return typeof input === 'boolean' +} |