Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de> Co-authored-by: Pytal <24800714+Pytal@users.noreply.github.com> Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>tags/v29.0.0beta1
@@ -99,7 +99,7 @@ export default { | |||
flex-direction: column; | |||
margin: 10px 32px 10px 0; | |||
gap: 16px 0; | |||
color: var(--color-text-lighter); | |||
color: var(--color-text-maxcontrast); | |||
&__groups, | |||
&__quota { | |||
@@ -117,7 +117,7 @@ export default { | |||
font-weight: bold; | |||
} | |||
&::v-deep .material-design-icon { | |||
&:deep(.material-design-icon) { | |||
align-self: flex-start; | |||
margin-top: 2px; | |||
} |
@@ -23,63 +23,69 @@ | |||
<template> | |||
<div> | |||
<div class="email"> | |||
<input :id="inputIdWithDefault" | |||
<NcInputField :id="inputIdWithDefault" | |||
ref="email" | |||
type="email" | |||
autocapitalize="none" | |||
autocomplete="email" | |||
:aria-label="inputPlaceholder" | |||
:error="hasError || !!helperText" | |||
:helper-text="helperText || undefined" | |||
:label="inputPlaceholder" | |||
:placeholder="inputPlaceholder" | |||
:value="email" | |||
:aria-describedby="helperText ? `${inputIdWithDefault}-helper-text` : undefined" | |||
autocapitalize="none" | |||
spellcheck="false" | |||
@input="onEmailChange"> | |||
<div class="email__actions-container"> | |||
<transition name="fade"> | |||
<Check v-if="showCheckmarkIcon" :size="20" /> | |||
<AlertOctagon v-else-if="showErrorIcon" :size="20" /> | |||
</transition> | |||
<template v-if="!primary"> | |||
<FederationControl :readable="propertyReadable" | |||
:additional="true" | |||
:additional-value="email" | |||
:disabled="federationDisabled" | |||
:handle-additional-scope-change="saveAdditionalEmailScope" | |||
:scope.sync="localScope" | |||
@update:scope="onScopeChange" /> | |||
</template> | |||
<NcActions class="email__actions" | |||
:aria-label="t('settings', 'Email options')" | |||
:force-menu="true"> | |||
<NcActionButton :aria-label="deleteEmailLabel" | |||
:close-after-click="true" | |||
:disabled="deleteDisabled" | |||
icon="icon-delete" | |||
@click.stop.prevent="deleteEmail"> | |||
{{ deleteEmailLabel }} | |||
</NcActionButton> | |||
<NcActionButton v-if="!primary || !isNotificationEmail" | |||
:aria-label="setNotificationMailLabel" | |||
:close-after-click="true" | |||
:disabled="setNotificationMailDisabled" | |||
icon="icon-favorite" | |||
@click.stop.prevent="setNotificationMail"> | |||
{{ setNotificationMailLabel }} | |||
</NcActionButton> | |||
:success="isSuccess" | |||
type="email" | |||
:value.sync="emailAddress" /> | |||
<div class="email__actions"> | |||
<NcActions :aria-label="actionsLabel" @close="showFederationSettings = false"> | |||
<template v-if="showFederationSettings"> | |||
<NcActionButton @click="showFederationSettings = false"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="mdiArrowLeft" /> | |||
</template> | |||
{{ t('settings', 'Back') }} | |||
</NcActionButton> | |||
<FederationControlActions :readable="propertyReadable" | |||
:additional="true" | |||
:additional-value="email" | |||
:disabled="federationDisabled" | |||
:handle-additional-scope-change="saveAdditionalEmailScope" | |||
:scope.sync="localScope" | |||
@update:scope="onScopeChange" /> | |||
</template> | |||
<template v-else> | |||
<NcActionButton v-if="!federationDisabled && !primary" | |||
@click="showFederationSettings = true"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="mdiLock" /> | |||
</template> | |||
{{ t('settings', 'Change scope level of {property}', { property: propertyReadable.toLocaleLowerCase() }) }} | |||
</NcActionButton> | |||
<NcActionCaption v-if="!isConfirmedAddress" | |||
:name="t('settings', 'This address is not confirmed')" /> | |||
<NcActionButton close-after-click | |||
:disabled="deleteDisabled" | |||
@click="deleteEmail"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="mdiTrashCan" /> | |||
</template> | |||
{{ deleteEmailLabel }} | |||
</NcActionButton> | |||
<NcActionButton v-if="!primary || !isNotificationEmail" | |||
close-after-click | |||
:disabled="!isConfirmedAddress" | |||
@click="setNotificationMail"> | |||
<template #icon> | |||
<NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" /> | |||
<NcIconSvgWrapper v-else :path="mdiStarOutline" /> | |||
</template> | |||
{{ setNotificationMailLabel }} | |||
</NcActionButton> | |||
</template> | |||
</NcActions> | |||
</div> | |||
</div> | |||
<p v-if="helperText" | |||
:id="`${inputIdWithDefault}-helper-text`" | |||
class="email__helper-text-message email__helper-text-message--error"> | |||
<AlertCircle class="email__helper-text-message__icon" :size="18" /> | |||
{{ helperText }} | |||
</p> | |||
<em v-if="isNotificationEmail"> | |||
{{ t('settings', 'Primary email for password reset and notifications') }} | |||
</em> | |||
@@ -89,12 +95,13 @@ | |||
<script> | |||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' | |||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | |||
import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue' | |||
import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue' | |||
import Check from 'vue-material-design-icons/Check.vue' | |||
import NcActionCaption from '@nextcloud/vue/dist/Components/NcActionCaption.js' | |||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | |||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' | |||
import debounce from 'debounce' | |||
import FederationControl from '../shared/FederationControl.vue' | |||
import { mdiArrowLeft, mdiLock, mdiStar, mdiStarOutline, mdiTrashCan } from '@mdi/js' | |||
import FederationControlActions from '../shared/FederationControlActions.vue' | |||
import { handleError } from '../../../utils/handlers.js' | |||
import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js' | |||
@@ -114,10 +121,10 @@ export default { | |||
components: { | |||
NcActions, | |||
NcActionButton, | |||
AlertCircle, | |||
AlertOctagon, | |||
Check, | |||
FederationControl, | |||
NcActionCaption, | |||
NcIconSvgWrapper, | |||
NcInputField, | |||
FederationControlActions, | |||
}, | |||
props: { | |||
@@ -152,19 +159,38 @@ export default { | |||
}, | |||
}, | |||
setup() { | |||
return { | |||
mdiArrowLeft, | |||
mdiLock, | |||
mdiStar, | |||
mdiStarOutline, | |||
mdiTrashCan, | |||
saveAdditionalEmailScope, | |||
} | |||
}, | |||
data() { | |||
return { | |||
propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, | |||
hasError: false, | |||
helperText: null, | |||
initialEmail: this.email, | |||
isSuccess: false, | |||
localScope: this.scope, | |||
saveAdditionalEmailScope, | |||
helperText: null, | |||
showCheckmarkIcon: false, | |||
showErrorIcon: false, | |||
propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, | |||
showFederationSettings: false, | |||
} | |||
}, | |||
computed: { | |||
actionsLabel() { | |||
if (this.primary) { | |||
return t('settings', 'Email options') | |||
} else { | |||
return t('settings', 'Options for additional email address {index}', { index: this.index + 1 }) | |||
} | |||
}, | |||
deleteDisabled() { | |||
if (this.primary) { | |||
// Disable for empty primary email as there is nothing to delete | |||
@@ -183,15 +209,13 @@ export default { | |||
return t('settings', 'Delete email') | |||
}, | |||
setNotificationMailDisabled() { | |||
return !this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED | |||
isConfirmedAddress() { | |||
return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED | |||
}, | |||
setNotificationMailLabel() { | |||
setNotificationMailLabel() { | |||
if (this.isNotificationEmail) { | |||
return t('settings', 'Unset as primary email') | |||
} else if (!this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED) { | |||
return t('settings', 'This address is not confirmed') | |||
} | |||
return t('settings', 'Set as primary email') | |||
}, | |||
@@ -213,25 +237,30 @@ export default { | |||
return (this.email && this.email === this.activeNotificationEmail) | |||
|| (this.primary && this.activeNotificationEmail === '') | |||
}, | |||
emailAddress: { | |||
get() { | |||
return this.email | |||
}, | |||
set(value) { | |||
this.$emit('update:email', value) | |||
this.debounceEmailChange(value.trim()) | |||
}, | |||
}, | |||
}, | |||
mounted() { | |||
if (!this.primary && this.initialEmail === '') { | |||
// $nextTick is needed here, otherwise it may not always work https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725 | |||
// $nextTick is needed here, otherwise it may not always work | |||
// https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725 | |||
this.$nextTick(() => this.$refs.email?.focus()) | |||
} | |||
}, | |||
methods: { | |||
onEmailChange(e) { | |||
this.$emit('update:email', e.target.value) | |||
this.debounceEmailChange(e.target.value.trim()) | |||
}, | |||
debounceEmailChange: debounce(async function(email) { | |||
this.helperText = null | |||
if (this.$refs.email?.validationMessage) { | |||
this.helperText = this.$refs.email.validationMessage | |||
this.helperText = this.$refs.email?.$refs.input?.validationMessage || null | |||
if (this.helperText !== null) { | |||
return | |||
} | |||
if (validateEmail(email) || email === '') { | |||
@@ -356,12 +385,12 @@ export default { | |||
} else if (notificationEmail !== undefined) { | |||
this.$emit('update:notification-email', notificationEmail) | |||
} | |||
this.showCheckmarkIcon = true | |||
setTimeout(() => { this.showCheckmarkIcon = false }, 2000) | |||
this.isSuccess = true | |||
setTimeout(() => { this.isSuccess = false }, 2000) | |||
} else { | |||
handleError(error, errorMessage) | |||
this.showErrorIcon = true | |||
setTimeout(() => { this.showErrorIcon = false }, 2000) | |||
this.hasError = true | |||
setTimeout(() => { this.hasError = false }, 2000) | |||
} | |||
}, | |||
@@ -374,66 +403,16 @@ export default { | |||
<style lang="scss" scoped> | |||
.email { | |||
display: grid; | |||
align-items: center; | |||
input { | |||
grid-area: 1 / 1; | |||
width: 100%; | |||
} | |||
.email__actions-container { | |||
grid-area: 1 / 1; | |||
justify-self: flex-end; | |||
height: 30px; | |||
display: flex; | |||
flex-direction: row; | |||
align-items: start; | |||
gap: 4px; | |||
&__actions { | |||
display: flex; | |||
gap: 0 2px; | |||
margin-right: 5px; | |||
.email__actions { | |||
&:hover, | |||
&:focus, | |||
&:active { | |||
opacity: 0.8 !important; | |||
} | |||
&::v-deep button { | |||
height: 30px !important; | |||
min-height: 30px !important; | |||
width: 30px !important; | |||
min-width: 30px !important; | |||
} | |||
} | |||
} | |||
&__helper-text-message { | |||
padding: 4px 0; | |||
display: flex; | |||
align-items: center; | |||
&__icon { | |||
margin-right: 8px; | |||
align-self: start; | |||
margin-top: 4px; | |||
} | |||
&--error { | |||
color: var(--color-error); | |||
} | |||
margin-top: 6px; | |||
} | |||
} | |||
.fade-enter, | |||
.fade-leave-to { | |||
opacity: 0; | |||
} | |||
.fade-enter-active { | |||
transition: opacity 200ms ease-out; | |||
} | |||
.fade-leave-active { | |||
transition: opacity 300ms ease-out; | |||
} | |||
</style> |
@@ -199,10 +199,6 @@ export default { | |||
section { | |||
padding: 10px 10px; | |||
&::v-deep button:disabled { | |||
cursor: default; | |||
} | |||
.additional-emails-label { | |||
display: block; | |||
margin-top: 16px; |
@@ -22,23 +22,15 @@ | |||
<template> | |||
<div class="language"> | |||
<select :id="inputId" @change="onLanguageChange"> | |||
<option v-for="commonLanguage in commonLanguages" | |||
:key="commonLanguage.code" | |||
:selected="language.code === commonLanguage.code" | |||
:value="commonLanguage.code"> | |||
{{ commonLanguage.name }} | |||
</option> | |||
<option disabled> | |||
────────── | |||
</option> | |||
<option v-for="otherLanguage in otherLanguages" | |||
:key="otherLanguage.code" | |||
:selected="language.code === otherLanguage.code" | |||
:value="otherLanguage.code"> | |||
{{ otherLanguage.name }} | |||
</option> | |||
</select> | |||
<NcSelect :aria-label-listbox="t('settings', 'Languages')" | |||
class="language__select" | |||
:clearable="false" | |||
:input-id="inputId" | |||
label="name" | |||
label-outside | |||
:options="allLanguages" | |||
:value="language" | |||
@option:selected="onLanguageChange" /> | |||
<a href="https://www.transifex.com/nextcloud/nextcloud/" | |||
target="_blank" | |||
@@ -54,9 +46,15 @@ import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/Person | |||
import { validateLanguage } from '../../../utils/validate.js' | |||
import { handleError } from '../../../utils/handlers.js' | |||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' | |||
export default { | |||
name: 'Language', | |||
components: { | |||
NcSelect, | |||
}, | |||
props: { | |||
inputId: { | |||
type: String, | |||
@@ -83,17 +81,18 @@ export default { | |||
}, | |||
computed: { | |||
/** | |||
* All available languages, sorted like: current, common, other | |||
*/ | |||
allLanguages() { | |||
return Object.freeze( | |||
[...this.commonLanguages, ...this.otherLanguages] | |||
.reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}), | |||
) | |||
const common = this.commonLanguages.filter(l => l.code !== this.language.code) | |||
const other = this.otherLanguages.filter(l => l.code !== this.language.code) | |||
return [this.language, ...common, ...other] | |||
}, | |||
}, | |||
methods: { | |||
async onLanguageChange(e) { | |||
const language = this.constructLanguage(e.target.value) | |||
async onLanguageChange(language) { | |||
this.$emit('update:language', language) | |||
if (validateLanguage(language)) { | |||
@@ -108,7 +107,7 @@ export default { | |||
language, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
this.reloadPage() | |||
window.location.reload() | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update language'), | |||
@@ -117,13 +116,6 @@ export default { | |||
} | |||
}, | |||
constructLanguage(languageCode) { | |||
return { | |||
code: languageCode, | |||
name: this.allLanguages[languageCode], | |||
} | |||
}, | |||
handleResponse({ language, status, errorMessage, error }) { | |||
if (status === 'ok') { | |||
// Ensure that local state reflects server state | |||
@@ -132,10 +124,6 @@ export default { | |||
handleError(error, errorMessage) | |||
} | |||
}, | |||
reloadPage() { | |||
location.reload() | |||
}, | |||
}, | |||
} | |||
</script> | |||
@@ -144,12 +132,11 @@ export default { | |||
.language { | |||
display: grid; | |||
select { | |||
width: 100%; | |||
#{&}__select { | |||
margin-top: 6px; // align with other inputs | |||
} | |||
a { | |||
color: var(--color-main-text); | |||
text-decoration: none; | |||
width: max-content; | |||
} |
@@ -25,12 +25,11 @@ | |||
<HeaderBar :input-id="inputId" | |||
:readable="propertyReadable" /> | |||
<template v-if="isEditable"> | |||
<Language :input-id="inputId" | |||
:common-languages="commonLanguages" | |||
:other-languages="otherLanguages" | |||
:language.sync="language" /> | |||
</template> | |||
<Language v-if="isEditable" | |||
:input-id="inputId" | |||
:common-languages="commonLanguages" | |||
:other-languages="otherLanguages" | |||
:language.sync="language" /> | |||
<span v-else> | |||
{{ t('settings', 'No language set') }} | |||
@@ -56,11 +55,17 @@ export default { | |||
HeaderBar, | |||
}, | |||
data() { | |||
setup() { | |||
// Non reactive instance properties | |||
return { | |||
propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, | |||
commonLanguages, | |||
otherLanguages, | |||
propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, | |||
} | |||
}, | |||
data() { | |||
return { | |||
language: activeLanguage, | |||
} | |||
}, | |||
@@ -80,9 +85,5 @@ export default { | |||
<style lang="scss" scoped> | |||
section { | |||
padding: 10px 10px; | |||
&::v-deep button:disabled { | |||
cursor: default; | |||
} | |||
} | |||
</style> |
@@ -22,26 +22,18 @@ | |||
<template> | |||
<div class="locale"> | |||
<select :id="inputId" @change="onLocaleChange"> | |||
<option v-for="currentLocale in localesForLanguage" | |||
:key="currentLocale.code" | |||
:selected="locale.code === currentLocale.code" | |||
:value="currentLocale.code"> | |||
{{ currentLocale.name }} | |||
</option> | |||
<option disabled> | |||
────────── | |||
</option> | |||
<option v-for="currentLocale in otherLocales" | |||
:key="currentLocale.code" | |||
:selected="locale.code === currentLocale.code" | |||
:value="currentLocale.code"> | |||
{{ currentLocale.name }} | |||
</option> | |||
</select> | |||
<NcSelect :aria-label-listbox="t('settings', 'Locales')" | |||
class="locale__select" | |||
:clearable="false" | |||
:input-id="inputId" | |||
label="name" | |||
label-outside | |||
:options="allLocales" | |||
:value="locale" | |||
@option:selected="updateLocale" /> | |||
<div class="example"> | |||
<Web :size="20" /> | |||
<MapClock :size="20" /> | |||
<div class="example__text"> | |||
<p> | |||
<span>{{ example.date }}</span> | |||
@@ -57,18 +49,19 @@ | |||
<script> | |||
import moment from '@nextcloud/moment' | |||
import Web from 'vue-material-design-icons/Web.vue' | |||
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' | |||
import MapClock from 'vue-material-design-icons/MapClock.vue' | |||
import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' | |||
import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' | |||
import { validateLocale } from '../../../utils/validate.js' | |||
import { handleError } from '../../../utils/handlers.js' | |||
export default { | |||
name: 'Locale', | |||
components: { | |||
Web, | |||
MapClock, | |||
NcSelect, | |||
}, | |||
props: { | |||
@@ -93,6 +86,7 @@ export default { | |||
data() { | |||
return { | |||
initialLocale: this.locale, | |||
intervalId: 0, | |||
example: { | |||
date: moment().format('L'), | |||
time: moment().format('LTS'), | |||
@@ -102,28 +96,25 @@ export default { | |||
}, | |||
computed: { | |||
/** | |||
* All available locale, sorted like: current, common, other | |||
*/ | |||
allLocales() { | |||
return Object.freeze( | |||
[...this.localesForLanguage, ...this.otherLocales] | |||
.reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}), | |||
) | |||
const common = this.localesForLanguage.filter(l => l.code !== this.locale.code) | |||
const other = this.otherLocales.filter(l => l.code !== this.locale.code) | |||
return [this.locale, ...common, ...other] | |||
}, | |||
}, | |||
created() { | |||
setInterval(this.refreshExample, 1000) | |||
mounted() { | |||
this.intervalId = window.setInterval(this.refreshExample, 1000) | |||
}, | |||
methods: { | |||
async onLocaleChange(e) { | |||
const locale = this.constructLocale(e.target.value) | |||
this.$emit('update:locale', locale) | |||
if (validateLocale(locale)) { | |||
await this.updateLocale(locale) | |||
} | |||
}, | |||
beforeDestroy() { | |||
window.clearInterval(this.intervalId) | |||
}, | |||
methods: { | |||
async updateLocale(locale) { | |||
try { | |||
const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE, locale.code) | |||
@@ -131,7 +122,7 @@ export default { | |||
locale, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
this.reloadPage() | |||
window.location.reload() | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update locale'), | |||
@@ -140,13 +131,6 @@ export default { | |||
} | |||
}, | |||
constructLocale(localeCode) { | |||
return { | |||
code: localeCode, | |||
name: this.allLocales[localeCode], | |||
} | |||
}, | |||
handleResponse({ locale, status, errorMessage, error }) { | |||
if (status === 'ok') { | |||
this.initialLocale = locale | |||
@@ -163,10 +147,6 @@ export default { | |||
firstDayOfWeek: window.dayNames[window.firstDay], | |||
} | |||
}, | |||
reloadPage() { | |||
location.reload() | |||
}, | |||
}, | |||
} | |||
</script> | |||
@@ -175,8 +155,8 @@ export default { | |||
.locale { | |||
display: grid; | |||
select { | |||
width: 100%; | |||
#{&}__select { | |||
margin-top: 6px; // align with other inputs | |||
} | |||
} | |||
@@ -184,9 +164,9 @@ export default { | |||
margin: 10px 0; | |||
display: flex; | |||
gap: 0 10px; | |||
color: var(--color-text-lighter); | |||
color: var(--color-text-maxcontrast); | |||
&::v-deep .material-design-icon { | |||
&:deep(.material-design-icon) { | |||
align-self: flex-start; | |||
margin-top: 2px; | |||
} |
@@ -25,12 +25,11 @@ | |||
<HeaderBar :input-id="inputId" | |||
:readable="propertyReadable" /> | |||
<template v-if="isEditable"> | |||
<Locale :input-id="inputId" | |||
:locales-for-language="localesForLanguage" | |||
:other-locales="otherLocales" | |||
:locale.sync="locale" /> | |||
</template> | |||
<Locale v-if="isEditable" | |||
:input-id="inputId" | |||
:locales-for-language="localesForLanguage" | |||
:other-locales="otherLocales" | |||
:locale.sync="locale" /> | |||
<span v-else> | |||
{{ t('settings', 'No locale set') }} | |||
@@ -80,9 +79,5 @@ export default { | |||
<style lang="scss" scoped> | |||
section { | |||
padding: 10px 10px; | |||
&::v-deep button:disabled { | |||
cursor: default; | |||
} | |||
} | |||
</style> |
@@ -26,7 +26,7 @@ | |||
:checked.sync="isProfileEnabled" | |||
:loading="loading" | |||
@update:checked="saveEnableProfile"> | |||
{{ t('settings', 'Enable Profile') }} | |||
{{ t('settings', 'Enable profile') }} | |||
</NcCheckboxRadioSwitch> | |||
</div> | |||
</template> |
@@ -33,40 +33,31 @@ | |||
:id="inputId" | |||
autocapitalize="none" | |||
autocomplete="off" | |||
:error="hasError || !!helperText" | |||
:helper-text="helperText" | |||
label-outside | |||
:placeholder="placeholder" | |||
rows="8" | |||
spellcheck="false" | |||
:success="isSuccess" | |||
:value.sync="inputValue" /> | |||
<NcInputField v-else | |||
:id="inputId" | |||
ref="input" | |||
:aria-describedby="helperText ? `${name}-helper-text` : undefined" | |||
autocapitalize="none" | |||
:autocomplete="autocomplete" | |||
:error="hasError || !!helperText" | |||
:helper-text="helperText" | |||
label-outside | |||
:placeholder="placeholder" | |||
spellcheck="false" | |||
:success="isSuccess" | |||
:type="type" | |||
:value.sync="inputValue" /> | |||
<div class="property__actions-container"> | |||
<Transition name="fade"> | |||
<Check v-if="showCheckmarkIcon" :size="20" /> | |||
<AlertOctagon v-else-if="showErrorIcon" :size="20" /> | |||
</Transition> | |||
</div> | |||
</div> | |||
<span v-else> | |||
{{ value || t('settings', 'No {property} set', { property: readable.toLocaleLowerCase() }) }} | |||
</span> | |||
<p v-if="helperText" | |||
:id="`${name}-helper-text`" | |||
class="property__helper-text-message property__helper-text-message--error"> | |||
<AlertCircle class="property__helper-text-message__icon" :size="18" /> | |||
{{ helperText }} | |||
</p> | |||
</section> | |||
</template> | |||
@@ -74,9 +65,6 @@ | |||
import debounce from 'debounce' | |||
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' | |||
import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js' | |||
import AlertCircle from 'vue-material-design-icons/AlertCircleOutline.vue' | |||
import AlertOctagon from 'vue-material-design-icons/AlertOctagon.vue' | |||
import Check from 'vue-material-design-icons/Check.vue' | |||
import HeaderBar from './HeaderBar.vue' | |||
@@ -87,9 +75,6 @@ export default { | |||
name: 'AccountPropertySection', | |||
components: { | |||
AlertCircle, | |||
AlertOctagon, | |||
Check, | |||
HeaderBar, | |||
NcInputField, | |||
NcTextArea, | |||
@@ -147,9 +132,9 @@ export default { | |||
data() { | |||
return { | |||
initialValue: this.value, | |||
helperText: null, | |||
showCheckmarkIcon: false, | |||
showErrorIcon: false, | |||
helperText: '', | |||
isSuccess: false, | |||
hasError: false, | |||
} | |||
}, | |||
@@ -170,12 +155,13 @@ export default { | |||
debouncePropertyChange() { | |||
return debounce(async function(value) { | |||
this.helperText = null | |||
if (this.$refs.input && this.$refs.input.validationMessage) { | |||
this.helperText = this.$refs.input.validationMessage | |||
this.helperText = this.$refs.input?.$refs.input?.validationMessage || '' | |||
if (this.helperText !== '') { | |||
return | |||
} | |||
if (this.onValidate && !this.onValidate(value)) { | |||
this.hasError = this.onValidate && !this.onValidate(value) | |||
if (this.hasError) { | |||
this.helperText = t('settings', 'Invalid value') | |||
return | |||
} | |||
await this.updateProperty(value) | |||
@@ -208,13 +194,13 @@ export default { | |||
if (this.onSave) { | |||
this.onSave(value) | |||
} | |||
this.showCheckmarkIcon = true | |||
setTimeout(() => { this.showCheckmarkIcon = false }, 2000) | |||
this.isSuccess = true | |||
setTimeout(() => { this.isSuccess = false }, 2000) | |||
} else { | |||
this.$emit('update:value', this.initialValue) | |||
handleError(error, errorMessage) | |||
this.showErrorIcon = true | |||
setTimeout(() => { this.showErrorIcon = false }, 2000) | |||
this.hasError = true | |||
setTimeout(() => { this.hasError = false }, 2000) | |||
} | |||
}, | |||
}, | |||
@@ -226,25 +212,15 @@ section { | |||
padding: 10px 10px; | |||
.property { | |||
display: grid; | |||
align-items: center; | |||
textarea { | |||
resize: vertical; | |||
grid-area: 1 / 1; | |||
width: 100%; | |||
} | |||
input { | |||
grid-area: 1 / 1; | |||
width: 100%; | |||
} | |||
display: flex; | |||
flex-direction: row; | |||
align-items: start; | |||
gap: 4px; | |||
.property__actions-container { | |||
grid-area: 1 / 1; | |||
margin-top: 6px; | |||
justify-self: flex-end; | |||
align-self: flex-end; | |||
height: 30px; | |||
display: flex; | |||
gap: 0 2px; |
@@ -2,6 +2,7 @@ | |||
- @copyright 2021, Christopher Ng <chrng8@gmail.com> | |||
- | |||
- @author Christopher Ng <chrng8@gmail.com> | |||
- @author Ferdinand Thiessen <opensource@fthiessen.de> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
@@ -25,51 +26,37 @@ | |||
class="federation-actions" | |||
:class="{ 'federation-actions--additional': additional }" | |||
:aria-label="ariaLabel" | |||
:default-icon="scopeIcon" | |||
:disabled="disabled"> | |||
<NcActionButton v-for="federationScope in federationScopes" | |||
:key="federationScope.name" | |||
:close-after-click="true" | |||
:disabled="!supportedScopes.includes(federationScope.name)" | |||
:icon="federationScope.iconClass" | |||
:name="federationScope.displayName" | |||
type="radio" | |||
:value="federationScope.name" | |||
:model-value="scope" | |||
@update:modelValue="changeScope"> | |||
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }} | |||
</NcActionButton> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="scopeIcon" /> | |||
</template> | |||
<FederationControlActions :additional="additional" | |||
:additional-value="additionalValue" | |||
:handle-additional-scope-change="handleAdditionalScopeChange" | |||
:readable="readable" | |||
:scope="scope" | |||
@update:scope="onUpdateScope" /> | |||
</NcActions> | |||
</template> | |||
<script> | |||
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js' | |||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | |||
import { loadState } from '@nextcloud/initial-state' | |||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | |||
import { | |||
ACCOUNT_PROPERTY_READABLE_ENUM, | |||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, | |||
PROFILE_READABLE_ENUM, | |||
PROPERTY_READABLE_KEYS_ENUM, | |||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, | |||
SCOPE_ENUM, SCOPE_PROPERTY_ENUM, | |||
UNPUBLISHED_READABLE_PROPERTIES, | |||
SCOPE_PROPERTY_ENUM, | |||
} from '../../../constants/AccountPropertyConstants.js' | |||
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js' | |||
import { handleError } from '../../../utils/handlers.js' | |||
const { | |||
federationEnabled, | |||
lookupServerUploadEnabled, | |||
} = loadState('settings', 'accountParameters', {}) | |||
import FederationControlActions from './FederationControlActions.vue' | |||
export default { | |||
name: 'FederationControl', | |||
components: { | |||
NcActions, | |||
NcActionButton, | |||
NcIconSvgWrapper, | |||
FederationControlActions, | |||
}, | |||
props: { | |||
@@ -103,7 +90,6 @@ export default { | |||
data() { | |||
return { | |||
readableLowerCase: this.readable.toLocaleLowerCase(), | |||
initialScope: this.scope, | |||
} | |||
}, | |||
@@ -117,84 +103,16 @@ export default { | |||
}, | |||
scopeIcon() { | |||
return SCOPE_PROPERTY_ENUM[this.scope].iconClass | |||
}, | |||
federationScopes() { | |||
return Object.values(SCOPE_PROPERTY_ENUM) | |||
}, | |||
supportedScopes() { | |||
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable] | |||
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) { | |||
return scopes | |||
} | |||
if (federationEnabled) { | |||
scopes.push(SCOPE_ENUM.FEDERATED) | |||
} | |||
if (lookupServerUploadEnabled) { | |||
scopes.push(SCOPE_ENUM.PUBLISHED) | |||
} | |||
return scopes | |||
return SCOPE_PROPERTY_ENUM[this.scope].icon | |||
}, | |||
}, | |||
methods: { | |||
async changeScope(scope) { | |||
onUpdateScope(scope) { | |||
this.$emit('update:scope', scope) | |||
if (!this.additional) { | |||
await this.updatePrimaryScope(scope) | |||
} else { | |||
await this.updateAdditionalScope(scope) | |||
} | |||
// TODO: provide focus method from NcActions | |||
this.$refs.federationActions.$refs.menuButton.$el.focus() | |||
}, | |||
async updatePrimaryScope(scope) { | |||
try { | |||
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope) | |||
this.handleResponse({ | |||
scope, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }), | |||
error: e, | |||
}) | |||
} | |||
}, | |||
async updateAdditionalScope(scope) { | |||
try { | |||
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope) | |||
this.handleResponse({ | |||
scope, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }), | |||
error: e, | |||
}) | |||
} | |||
}, | |||
handleResponse({ scope, status, errorMessage, error }) { | |||
if (status === 'ok') { | |||
this.initialScope = scope | |||
} else { | |||
this.$emit('update:scope', this.initialScope) | |||
handleError(error, errorMessage) | |||
} | |||
}, | |||
}, | |||
} | |||
</script> |
@@ -0,0 +1,181 @@ | |||
<!-- | |||
- @copyright 2021, Christopher Ng <chrng8@gmail.com> | |||
- | |||
- @author Christopher Ng <chrng8@gmail.com> | |||
- @author Ferdinand Thiessen <opensource@fthiessen.de> | |||
- | |||
- @license GNU AGPL version 3 or any later version | |||
- | |||
- This program is free software: you can redistribute it and/or modify | |||
- it under the terms of the GNU Affero General Public License as | |||
- published by the Free Software Foundation, either version 3 of the | |||
- License, or (at your option) any later version. | |||
- | |||
- This program is distributed in the hope that it will be useful, | |||
- but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
- GNU Affero General Public License for more details. | |||
- | |||
- You should have received a copy of the GNU Affero General Public License | |||
- along with this program. If not, see <http://www.gnu.org/licenses/>. | |||
- | |||
--> | |||
<template> | |||
<Fragment> | |||
<NcActionButton v-for="federationScope in federationScopes" | |||
:key="federationScope.name" | |||
:close-after-click="true" | |||
:disabled="!supportedScopes.includes(federationScope.name)" | |||
:name="federationScope.displayName" | |||
type="radio" | |||
:value="federationScope.name" | |||
:model-value="scope" | |||
@update:modelValue="changeScope"> | |||
<template #icon> | |||
<NcIconSvgWrapper :path="federationScope.icon" /> | |||
</template> | |||
{{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }} | |||
</NcActionButton> | |||
</Fragment> | |||
</template> | |||
<script> | |||
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js' | |||
import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js' | |||
import { loadState } from '@nextcloud/initial-state' | |||
import { Fragment } from 'vue-frag' | |||
import { | |||
ACCOUNT_PROPERTY_READABLE_ENUM, | |||
ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, | |||
PROFILE_READABLE_ENUM, | |||
PROPERTY_READABLE_KEYS_ENUM, | |||
PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, | |||
SCOPE_ENUM, SCOPE_PROPERTY_ENUM, | |||
UNPUBLISHED_READABLE_PROPERTIES, | |||
} from '../../../constants/AccountPropertyConstants.js' | |||
import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js' | |||
import { handleError } from '../../../utils/handlers.js' | |||
const { | |||
federationEnabled, | |||
lookupServerUploadEnabled, | |||
} = loadState('settings', 'accountParameters', {}) | |||
export default { | |||
name: 'FederationControlActions', | |||
components: { | |||
Fragment, | |||
NcActionButton, | |||
NcIconSvgWrapper, | |||
}, | |||
props: { | |||
readable: { | |||
type: String, | |||
required: true, | |||
validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY, | |||
}, | |||
additional: { | |||
type: Boolean, | |||
default: false, | |||
}, | |||
additionalValue: { | |||
type: String, | |||
default: '', | |||
}, | |||
handleAdditionalScopeChange: { | |||
type: Function, | |||
default: null, | |||
}, | |||
scope: { | |||
type: String, | |||
required: true, | |||
}, | |||
}, | |||
data() { | |||
return { | |||
readableLowerCase: this.readable.toLocaleLowerCase(), | |||
initialScope: this.scope, | |||
} | |||
}, | |||
computed: { | |||
federationScopes() { | |||
return Object.values(SCOPE_PROPERTY_ENUM) | |||
}, | |||
supportedScopes() { | |||
const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable] | |||
if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) { | |||
return scopes | |||
} | |||
if (federationEnabled) { | |||
scopes.push(SCOPE_ENUM.FEDERATED) | |||
} | |||
if (lookupServerUploadEnabled) { | |||
scopes.push(SCOPE_ENUM.PUBLISHED) | |||
} | |||
return scopes | |||
}, | |||
}, | |||
methods: { | |||
async changeScope(scope) { | |||
this.$emit('update:scope', scope) | |||
if (!this.additional) { | |||
await this.updatePrimaryScope(scope) | |||
} else { | |||
await this.updateAdditionalScope(scope) | |||
} | |||
}, | |||
async updatePrimaryScope(scope) { | |||
try { | |||
const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope) | |||
this.handleResponse({ | |||
scope, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }), | |||
error: e, | |||
}) | |||
} | |||
}, | |||
async updateAdditionalScope(scope) { | |||
try { | |||
const responseData = await this.handleAdditionalScopeChange(this.additionalValue, scope) | |||
this.handleResponse({ | |||
scope, | |||
status: responseData.ocs?.meta?.status, | |||
}) | |||
} catch (e) { | |||
this.handleResponse({ | |||
errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }), | |||
error: e, | |||
}) | |||
} | |||
}, | |||
handleResponse({ scope, status, errorMessage, error }) { | |||
if (status === 'ok') { | |||
this.initialScope = scope | |||
} else { | |||
this.$emit('update:scope', this.initialScope) | |||
handleError(error, errorMessage) | |||
} | |||
}, | |||
}, | |||
} | |||
</script> |
@@ -24,6 +24,7 @@ | |||
* SYNC to be kept in sync with `lib/public/Accounts/IAccountManager.php` | |||
*/ | |||
import { mdiAccountGroup, mdiCellphone, mdiLock, mdiWeb } from '@mdi/js' | |||
import { translate as t } from '@nextcloud/l10n' | |||
/** Enum of account properties */ | |||
@@ -167,28 +168,28 @@ export const SCOPE_PROPERTY_ENUM = Object.freeze({ | |||
displayName: t('settings', 'Private'), | |||
tooltip: t('settings', 'Only visible to people matched via phone number integration through Talk on mobile'), | |||
tooltipDisabled: t('settings', 'Not available as this property is required for core functionality including file sharing and calendar invitations'), | |||
iconClass: 'icon-phone', | |||
icon: mdiCellphone, | |||
}, | |||
[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', | |||
icon: mdiLock, | |||
}, | |||
[SCOPE_ENUM.FEDERATED]: { | |||
name: SCOPE_ENUM.FEDERATED, | |||
displayName: t('settings', 'Federated'), | |||
tooltip: t('settings', 'Only synchronize to trusted servers'), | |||
tooltipDisabled: t('settings', 'Not available as federation has been disabled for your account, contact your system administration if you have any questions'), | |||
iconClass: 'icon-contacts-dark', | |||
icon: mdiAccountGroup, | |||
}, | |||
[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', 'Not available as publishing account specific data to the lookup server is not allowed, contact your system administration if you have any questions'), | |||
iconClass: 'icon-link', | |||
icon: mdiWeb, | |||
}, | |||
}) | |||