diff options
Diffstat (limited to 'src')
29 files changed, 936 insertions, 2256 deletions
diff --git a/src/components/ContactDetails.vue b/src/components/ContactDetails.vue index b466a6d5..530df74c 100644 --- a/src/components/ContactDetails.vue +++ b/src/components/ContactDetails.vue @@ -48,17 +48,17 @@ <!-- fullname, org, title --> <div id="contact-header-infos"> <h2> - <input id="contact-fullname" v-model="contact.fullName" :disabled="!contact.addressbook.enabled" + <input id="contact-fullname" v-model="contact.fullName" :disabled="!contact.addressbook.readOnly" :placeholder="t('contacts', 'Name')" type="text" autocomplete="off" autocorrect="off" spellcheck="false" name="fullname" value="" @input="debounceUpdateContact"> </h2> <div id="details-org-container"> - <input id="contact-org" v-model="contact.org" :disabled="!contact.addressbook.enabled" + <input id="contact-org" v-model="contact.org" :disabled="!contact.addressbook.readOnly" :placeholder="t('contacts', 'Company')" type="text" autocomplete="off" autocorrect="off" spellcheck="false" name="org" value="" @input="debounceUpdateContact"> - <input id="contact-title" v-model="contact.title" :disabled="!contact.addressbook.enabled" + <input id="contact-title" v-model="contact.title" :disabled="!contact.addressbook.readOnly" :placeholder="t('contacts', 'Title')" type="text" autocomplete="off" autocorrect="off" spellcheck="false" name="title" value="" @input="debounceUpdateContact"> @@ -67,15 +67,21 @@ <!-- actions --> <div id="contact-header-actions"> - <div v-click-outside="closeMenu" class="menu-icon icon-more-white" @click="toggleMenu" /> - <div :class="{ 'open': openedMenu }" class="popovermenu"> - <popover-menu :menu="contactActions" /> + <div v-tooltip.auto="warning" :class="{'icon-loading-small': loadingUpdate, 'icon-error-white menu-icon--pulse': warning}" class="menu-icon" /> + <div class="menu-icon"> + <div v-click-outside="closeMenu" class="icon-more-white" @click="toggleMenu" /> + <div :class="{ 'open': openedMenu }" class="popovermenu"> + <popover-menu :menu="contactActions" /> + </div> </div> </div> </header> + <!-- contact details loading --> + <section v-if="loadingData" class="icon-loading contact-details" /> + <!-- contact details --> - <section class="contact-details"> + <section v-else class="contact-details"> <!-- properties iteration --> <!-- using contact.key in the key and index as key to avoid conflicts between similar data and exact key --> @@ -85,7 +91,7 @@ <!-- addressbook change select - no last property because class is not applied here--> <property-select :prop-model="addressbookModel" :value.sync="addressbook" :is-first-property="true" - :is-last-property="false" :options="addressbooksOptions" class="property--addressbooks" /> + :is-last-property="false" class="property--addressbooks" /> <!-- new property select --> <add-new-prop :contact="contact" /> @@ -95,6 +101,7 @@ </template> <script> +import { PopoverMenu } from 'nextcloud-vue' import ClickOutside from 'vue-click-outside' import Vue from 'vue' import VTooltip from 'v-tooltip' @@ -103,7 +110,6 @@ import debounce from 'debounce' import Contact from '../models/contact' import rfcProps from '../models/rfcProps.js' -import PopoverMenu from './core/popoverMenu' import ContactProperty from './ContactDetails/ContactDetailsProperty' import AddNewProp from './ContactDetails/ContactDetailsAddNewProp' import PropertySelect from './Properties/PropertySelect' @@ -141,17 +147,36 @@ export default { data() { return { - openedMenu: false, - addressbookModel: { - readableName: t('contacts', 'Addressbook'), - icon: 'icon-addressbook' - } + /** + * Local off-store clone of the selected contact for edition + * because we can't edit contacts data outside the store. + * Every change will be dispatched and updated on the real + * store contact after a debounce. + */ + localContact: undefined, + loadingData: true, + loadingUpdate: false, + openedMenu: false } }, computed: { + + /** + * Warning message + * + * @returns {string} + */ + warning() { + if (!this.contact.dav) { + return t('contacts', 'This contact is not yet synced. Edit it to trigger a change.') + } + }, + /** * Contact color based on uid + * + * @returns {string} */ colorAvatar() { try { @@ -164,6 +189,8 @@ export default { /** * Header actions for the contact + * + * @returns {Array} */ contactActions() { let actions = [ @@ -173,7 +200,7 @@ export default { href: this.contact.url } ] - if (this.contact.addressbook.enabled) { + if (this.contact.addressbook.readOnly) { actions.push({ icon: 'icon-delete', text: t('contacts', 'Delete'), @@ -186,6 +213,8 @@ export default { /** * Contact properties copied and sorted by rfcProps.fieldOrder + * + * @returns {Array} */ sortedProperties() { return this.contact.properties.slice(0).sort((a, b) => { @@ -195,23 +224,42 @@ export default { }) }, - // usable addressbook object linked to the local contact + /** + * Fake model to use the propertySelect component + * + * @returns {Object} + */ + addressbookModel() { + return { + readableName: t('contacts', 'Addressbook'), + icon: 'icon-addressbook', + options: this.addressbooksOptions + } + }, + + /** + * Usable addressbook object linked to the local contact + * + * @param {string} [addressbookId] set the addressbook id + * @returns {string} + */ addressbook: { get: function() { - return { - id: this.contact.addressbook.id, - name: this.contact.addressbook.displayName - } + return this.contact.addressbook.id }, - set: function(addressbook) { - this.moveContactToAddressbook(addressbook) + set: function(addressbookId) { + this.moveContactToAddressbook(addressbookId) } }, - // store getters filtered and mapped to usable object + /** + * Store getters filtered and mapped to usable object + * + * @returns {Array} + */ addressbooksOptions() { return this.addressbooks - .filter(addressbook => addressbook.enabled) + .filter(addressbook => addressbook.readOnly) .map(addressbook => { return { id: addressbook.id, @@ -224,29 +272,37 @@ export default { addressbooks() { return this.$store.getters.getAddressbooks }, - - // local version of the contact contact() { - let contact = this.$store.getters.getContact(this.uid) - if (contact) { - // create empty contact and copy inner data - let localContact = new Contact( - 'BEGIN:VCARD\nUID:' + contact.uid + '\nEND:VCARD', - contact.addressbook - ) - localContact.updateContact(contact.jCal) - return localContact + return this.$store.getters.getContact(this.uid) + } + }, + + watch: { + contact: function() { + if (this.uid) { + this.selectContact(this.uid) } } }, + beforeMount() { + // load the desired data if we already selected a contact + if (this.uid) { + this.selectContact(this.uid) + } + }, + methods: { /** * Executed on the 'updatedcontact' event * Send the local clone of contact to the store */ updateContact() { + this.loadingUpdate = true this.$store.dispatch('updateContact', this.contact) + .then(() => { + this.loadingUpdate = false + }) }, /** @@ -266,28 +322,67 @@ export default { }, /** + * Select a contac, and update the localContact + * Fetch updated data if necessary + * + * @param {string} uid the contact uid + */ + selectContact(uid) { + // local version of the contact + this.loadingData = true + let contact = this.$store.getters.getContact(uid) + + // if contact exists AND if exists on server + if (contact && contact.dav) { + this.$store.dispatch('fetchFullContact', contact) + .then(() => { + // create empty contact and copy inner data + let localContact = new Contact( + 'BEGIN:VCARD\nUID:' + contact.uid + '\nEND:VCARD', + contact.addressbook + ) + localContact.updateContact(contact.jCal) + this.localContact = localContact + this.loadingData = false + }) + .catch((error) => { + OC.Notification.showTemporary(t('contacts', 'The contact doesn\'t exists anymore on the server.')) + console.error(error) + // trigger a local deletion from the store only + this.$store.dispatch('deleteContact', { contact: this.contact, dav: false }) + }) + } else if (contact) { + // create empty contact and copy inner data + // wait for an update to really push the contact on the server! + this.localContact = new Contact( + 'BEGIN:VCARD\nUID:' + contact.uid + '\nEND:VCARD', + contact.addressbook + ) + this.loadingData = false + } + }, + + /** * Dispatch contact deletion request */ deleteContact() { - this.$store.dispatch('deleteContact', this.contact) + this.$store.dispatch('deleteContact', { contact: this.contact }) }, /** * Move contact to the specified addressbook * - * @param {Object} addressbook the desired addressbook + * @param {string} addressbookId the desired addressbook ID */ - moveContactToAddressbook(addressbook) { - addressbook = this.addressbooks.find( - search => search.id === addressbook.id - ) - // we need to use the store contact, not the local contact - let contact = this.$store.getters.getContact(this.contact.key) + moveContactToAddressbook(addressbookId) { + let addressbook = this.addressbooks.find(search => search.id === addressbookId) // TODO Make sure we do not overwrite contacts if (addressbook) { this.$store .dispatch('moveContactToAddressbook', { - contact: contact, + // we need to use the store contact, not the local contact + // using this.contact and not this.localContact + contact: this.contact, addressbook }) .then(() => { diff --git a/src/components/ContactDetails/ContactDetailsProperty.vue b/src/components/ContactDetails/ContactDetailsProperty.vue index dd352517..289efe3a 100644 --- a/src/components/ContactDetails/ContactDetailsProperty.vue +++ b/src/components/ContactDetails/ContactDetailsProperty.vue @@ -22,10 +22,11 @@ <template> <!-- If not in the rfcProps then we don't want to display it --> - <component v-if="propModel && propType !== 'unknown'" :is="componentInstance" :select-type.sync="selectType" - :prop-model="propModel" :value.sync="value" :is-first-property="isFirstProperty" - :property="property" :is-last-property="isLastProperty" :class="{'property--last': isLastProperty}" - :contact="contact" @delete="deleteProp" /> + <component v-if="propModel && propType !== 'unknown'" + :is="componentInstance" :select-type.sync="selectType" :prop-model="propModel" + :value.sync="value" :is-first-property="isFirstProperty" :property="property" + :is-last-property="isLastProperty" :class="{'property--last': isLastProperty}" :contact="contact" + @delete="deleteProp" /> </template> <script> @@ -37,7 +38,7 @@ import PropertyText from '../Properties/PropertyText' import PropertyMultipleText from '../Properties/PropertyMultipleText' import PropertyDateTime from '../Properties/PropertyDateTime' import propertyGroups from '../Properties/PropertyGroups' -// import PropertySelect from '../Properties/PropertyMultipleText' +import PropertySelect from '../Properties/PropertySelect' export default { name: 'ContactDetailsProperty', @@ -76,6 +77,8 @@ export default { return PropertyMultipleText } else if (this.propType && ['date-and-or-time', 'date-time', 'time', 'date'].indexOf(this.propType) > -1) { return PropertyDateTime + } else if (this.propType && this.propType === 'select') { + return PropertySelect } else if (this.propType && this.propType !== 'unknown') { return PropertyText } @@ -111,7 +114,11 @@ export default { return this.property.name }, propType() { - return this.property.type + // if we have a force type set, use it! + if (this.propModel && this.propModel.force) { + return this.propModel.force + } + return this.property.getDefaultType() }, // template to use diff --git a/src/components/ContentList/ContentListItem.vue b/src/components/ContentList/ContentListItem.vue index 6c2ccdbf..88e6bb78 100644 --- a/src/components/ContentList/ContentListItem.vue +++ b/src/components/ContentList/ContentListItem.vue @@ -7,10 +7,14 @@ class="app-content-list-item-checkbox checkbox" @keypress.enter.space.prevent.stop="toggleSelect"> <label :for="contact.key" @click.prevent.stop="toggleSelect" @keypress.enter.space.prevent.stop="toggleSelect" /> --> - <div :style="{ 'backgroundColor': colorAvatar }" class="app-content-list-item-icon">{{ contact.displayName | firstLetter }}</div> + <div :style="{ 'backgroundColor': colorAvatar }" class="app-content-list-item-icon"> + {{ contact.displayName | firstLetter }} + <!-- try to fetch the avatar only if the contact exists on the server --> + <div v-if="contact.dav" :style="{ 'backgroundImage': avatarUrl }" class="app-content-list-item-icon__avatar" /> + </div> <div class="app-content-list-item-line-one">{{ contact.displayName }}</div> <div v-if="contact.email" class="app-content-list-item-line-two">{{ contact.email }}</div> - <div v-if="contact.addressbook.enabled" class="icon-delete" tabindex="0" + <div v-if="contact.addressbook.readOnly" class="icon-delete" tabindex="0" @click.prevent.stop="deleteContact" @keypress.enter.prevent.stop="deleteContact" /> </div> </template> @@ -48,6 +52,9 @@ export default { } catch (e) { return 'grey' } + }, + avatarUrl() { + return `url(${this.contact.url}?photo)` } }, methods: { @@ -64,7 +71,7 @@ export default { * Dispatch contact deletion request */ deleteContact() { - this.$store.dispatch('deleteContact', this.contact) + this.$store.dispatch('deleteContact', { contact: this.contact }) }, /** diff --git a/src/components/Properties/PropertyDateTime.vue b/src/components/Properties/PropertyDateTime.vue index 74f6ed72..db312bcd 100644 --- a/src/components/Properties/PropertyDateTime.vue +++ b/src/components/Properties/PropertyDateTime.vue @@ -23,7 +23,8 @@ <template> <div v-if="propModel" :class="`grid-span-${gridLength}`" class="property"> <!-- title if first element --> - <property-title v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" :readable-name="propModel.readableName" /> + <property-title v-if="isFirstProperty && propModel.icon" :icon="propModel.icon" :readable-name="propModel.readableName" + :info="propModel.info" /> <div class="property__row"> <!-- type selector --> @@ -41,24 +42,95 @@ <!-- delete the prop --> <button :title="t('contacts', 'Delete')" class="property__delete icon-delete" @click="deleteProperty" /> - <input v-model.trim="localValue" class="property__value" type="text" - @input="updateValue"> + <!-- Real input where the picker shows --> + <datetime-picker :value="localValue.toJSDate()" :minute-step="10" :lang="lang" + :clearable="false" :first-day-of-week="firstDay" :type="inputType" + confirm @confirm="updateValue" /> </div> </div> </template> <script> import Multiselect from 'vue-multiselect' -import propertyTitle from './PropertyTitle' +import { DatetimePicker } from 'nextcloud-vue' import debounce from 'debounce' +import moment from 'moment' import { VCardTime } from 'ical.js' +import propertyTitle from './PropertyTitle' + +/** + * Format time with locale to display only + * Using the Object as hared data since it's the only way + * for us to forcefully omit some data (no year, or no time... etc) + * and ths only common syntax between js Date, moment and VCardTime + * + * @param {Object} vcardTime ICAL.VCardTime data + * @param {string} type the input type e.g. date-time + * @param {string} locale the user locale + * @returns {string} + */ +let formatDateTime = function(vcardTime, type, locale) { + // this is the only possibility for us to ensure + // no data is lost. e.g. if no second are set + // the second will be null and not 0 + let datetimeData = vcardTime.toJSON() + let datetime = '' + /** + * Make sure to display the most interesting data. + * If the Object does not have any time, do not display + * the time and vice-versa. + */ + // No hour, no minute and no second = date only + if (datetimeData.hour === null && datetimeData.minute === null && datetimeData.second === null) { + datetime = moment(datetimeData) + .locale(locale) + .format('LL') + + // No year, no month and no day = time only + } else if (datetimeData.year === null && datetimeData.month === null && datetimeData.day === null) { + datetime = moment(datetimeData) + .locale(locale) + .format('LTS') + } + + // Fallback to the data ical.js provide us + if (datetime === '') { + datetime = moment(datetimeData) + .locale(locale) + .format( + type === 'datetime' + ? 'LLLL' // date & time display + : type === 'date' + ? 'LL' // only date + : 'LTS' // only time + ) + } + return datetimeData.year === null + // replace year and remove double spaces + ? datetime.replace(moment(vcardTime).year(), '').replace(/\s\s+/g, ' ') + : datetime +} + +/** + * Override format function and use this since this + * inside a function declaration will represent the + * location of the call. So this = DatetimePicker. + * Therefore we can use any props we pass through datetime-picker + * + * @returns {string} + */ +DatetimePicker.methods.stringify = function() { + return formatDateTime(this.$parent.localValue, this.type, this.$parent.locale) +} + export default { name: 'PropertyDateTime', components: { Multiselect, - propertyTitle + propertyTitle, + DatetimePicker }, props: { @@ -76,6 +148,11 @@ export default { default: '', required: true }, + property: { + type: Object, + default: () => {}, + required: true + }, isFirstProperty: { type: Boolean, default: true @@ -89,7 +166,25 @@ export default { data() { return { localValue: this.value, - localType: this.selectType + localType: this.selectType, + + // input type following DatePicker docs + inputType: this.property.getDefaultType() === 'date-time' || this.property.getDefaultType() === 'date-and-or-time' + ? 'datetime' + : this.property.getDefaultType() === 'date' + ? 'date' + : 'time', + + // locale and lang data + locale: 'en', // temporary value, see mounted + firstDay: window.firstDay, // provided by nextcloud + lang: { + days: window.dayNamesShort, // provided by nextcloud + months: window.monthNamesShort, // provided by nextcloud + placeholder: { + date: t('contacts', 'Select Date') + } + } } }, @@ -116,6 +211,31 @@ export default { } }, + mounted() { + // Load the locale + // convert format like en_GB to en-gb for `moment.js` + let locale = OC.getLocale().replace('_', '-').toLowerCase() + + // default load e.g. fr-fr + import('moment/locale/' + this.locale) + .then(e => { + // force locale change to update + // the component once done loading + this.locale = locale + }) + .catch(e => { + // failure: fallback to fr + import('moment/locale/' + locale.split('-')[0]) + .then(e => { + this.locale = locale.split('-')[0] + }) + .catch(e => { + // failure, fallback to english + this.locale = 'en' + }) + }) + }, + methods: { /** @@ -129,7 +249,22 @@ export default { * Debounce and send update event to parent */ updateValue: debounce(function(e) { + let rawData = moment(e).toArray() + + /** + * Use the current year to ensure we do not lose + * the year data on v4.0 since we currently have + * no options to remove the year selection. + */ + if (this.value.year === null) { + rawData[0] = null + } + + // reset the VCardTime component to the selected date/time + this.localValue.resetTo(...rawData) + // https://vuejs.org/v2/guide/components-custom-events.html#sync-Modifier + // Use moment to convert the JsDate to Object this.$emit('update:value', this.localValue) }, 500), diff --git a/src/components/Properties/PropertyGroups.vue b/src/components/Properties/PropertyGroups.vue index be871175..2ab97b93 100644 --- a/src/components/Properties/PropertyGroups.vue +++ b/src/components/Properties/PropertyGroups.vue @@ -85,6 +85,8 @@ export default { /** * Format array of groups objects to a string for the popup * Based on the ultiselect limit + * + * @returns {string} the additional groups */ formatGroupsTitle() { return this.localValue.slice(3).join(', ') diff --git a/src/components/Properties/PropertyMultipleText.vue b/src/components/Properties/PropertyMultipleText.vue index dcbf080f..6804c9e5 100644 --- a/src/components/Properties/PropertyMultipleText.vue +++ b/ |