contacts/src/components/ContactDetails.vue

436 lines
12 KiB
Vue

<!--
- @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.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 id="contact-details" class="app-content-details">
<!-- nothing selected or contact not found -->
<div v-if="!contact && !loading" id="emptycontent">
<div class="icon-contacts" />
<h2>{{ t('contacts', 'No contact selected') }}</h2>
<p>{{ t('contacts', 'Select a contact on the list to begin') }}</p>
</div>
<!-- loading -->
<div v-else-if="!contact && loading" id="emptycontent">
<div class="icon-contacts" />
<h2>{{ t('contacts', 'Loading') }}</h2>
</div>
<template v-else>
<!-- contact header -->
<header :style="{ 'backgroundColor': colorAvatar }">
<!-- avatar and upload photo -->
<contact-avatar :contact="contact" />
<!-- QUESTION: is it better to pass contact as a prop or get it from the store inside
contact-avatar ? :avatar="contact.photo"-->
<!-- fullname, org, title -->
<div id="contact-header-infos">
<h2>
<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.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.readOnly"
:placeholder="t('contacts', 'Title')" type="text" autocomplete="off"
autocorrect="off" spellcheck="false" name="title"
value="" @input="debounceUpdateContact">
</div>
</div>
<!-- actions -->
<div id="contact-header-actions">
<div v-tooltip.bottom="warning" :class="{'icon-loading-small': loadingUpdate, 'header-icon--pulse icon-error-white': warning}" class="header-icon" />
<div v-tooltip="{
content: conflict,
show: true,
trigger: 'manual',
}" v-if="conflict" class="header-icon header-icon--pulse icon-history-white"
@click="refreshContact" />
<div class="menu-icon">
<div v-click-outside="closeMenu" class="header-icon 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 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 -->
<contact-property v-for="(property, index) in sortedProperties" :key="index+contact.key" :index="index"
:sorted-properties="sortedProperties" :property="property" :contact="contact"
@updatedcontact="updateContact" />
<!-- 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" class="property--addressbooks" />
<!-- new property select -->
<add-new-prop :contact="contact" />
</section>
</template>
</div>
</template>
<script>
import { PopoverMenu } from 'nextcloud-vue'
import ClickOutside from 'vue-click-outside'
import Vue from 'vue'
import VTooltip from 'v-tooltip'
import debounce from 'debounce'
import Contact from '../models/contact'
import rfcProps from '../models/rfcProps.js'
import ContactProperty from './ContactDetails/ContactDetailsProperty'
import AddNewProp from './ContactDetails/ContactDetailsAddNewProp'
import PropertySelect from './Properties/PropertySelect'
import PropertyGroups from './Properties/PropertyGroups'
import ContactAvatar from './ContactDetails/ContactDetailsAvatar'
Vue.use(VTooltip)
export default {
name: 'ContactDetails',
components: {
PopoverMenu,
ContactProperty,
PropertySelect,
PropertyGroups,
AddNewProp,
ContactAvatar
},
directives: {
ClickOutside
},
props: {
loading: {
type: Boolean,
default: true
},
uid: {
type: String,
default: undefined
}
},
data() {
return {
/**
* 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|undefined}
*/
warning() {
if (!this.contact.dav) {
return t('contacts', 'This contact is not yet synced. Edit it to trigger a change.')
}
},
/**
* Conflict message
*
* @returns {string|undefined}
*/
conflict() {
if (this.contact.conflict) {
return t('contacts', 'The contact you were trying to edit has changed. Please manually refresh the contact. Any further edits will be discarded.')
}
},
/**
* Contact color based on uid
*
* @returns {string}
*/
colorAvatar() {
try {
let color = this.contact.uid.toRgb()
return `rgb(${color.r}, ${color.g}, ${color.b})`
} catch (e) {
return 'grey'
}
},
/**
* Header actions for the contact
*
* @returns {Array}
*/
contactActions() {
let actions = [
{
icon: 'icon-download',
text: t('contacts', 'Download'),
href: this.contact.url
}
]
if (!this.contact.addressbook.readOnly) {
actions.push({
icon: 'icon-delete',
text: t('contacts', 'Delete'),
action: this.deleteContact
})
}
return actions
},
/**
* Contact properties copied and sorted by rfcProps.fieldOrder
*
* @returns {Array}
*/
sortedProperties() {
return this.contact.properties.slice(0).sort((a, b) => {
return (
rfcProps.fieldOrder.indexOf(a.name) - rfcProps.fieldOrder.indexOf(b.name)
)
})
},
/**
* 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 this.contact.addressbook.id
},
set: function(addressbookId) {
this.moveContactToAddressbook(addressbookId)
}
},
/**
* Store getters filtered and mapped to usable object
*
* @returns {Array}
*/
addressbooksOptions() {
return this.addressbooks
.filter(addressbook => addressbook.readOnly)
.map(addressbook => {
return {
id: addressbook.id,
name: addressbook.displayName
}
})
},
// store getter
addressbooks() {
return this.$store.getters.getAddressbooks
},
contact() {
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
})
},
/**
* Debounce the contact update for the header props
* photo, fn, org, title
*/
debounceUpdateContact: debounce(function(e) {
this.updateContact()
}, 500),
// menu handling
closeMenu() {
this.openedMenu = false
},
toggleMenu() {
this.openedMenu = !this.openedMenu
},
/**
* Select a contac, and update the localContact
* Fetch updated data if necessary
* Scroll to the selected contact if exists
*
* @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) {
// if contact exists AND if exists on server
if (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 {
// 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
}
// scroll to selected contact if any
let list = document.getElementById('contacts-list')
let item = document.querySelector('#' + btoa(contact.key).slice(0, -2))
let isAbove = list.scrollTop > item.offsetTop
let isUnder = item.offsetTop + item.offsetHeight > list.scrollTop + list.offsetHeight
// check if contact outside visible list area
if (item && (isAbove || isUnder)) {
list.scrollTo(0, item.offsetTop - item.offsetHeight / 2)
}
}
},
/**
* Dispatch contact deletion request
*/
deleteContact() {
this.$store.dispatch('deleteContact', { contact: this.contact })
},
/**
* Move contact to the specified addressbook
*
* @param {string} addressbookId the desired addressbook ID
*/
moveContactToAddressbook(addressbookId) {
let addressbook = this.addressbooks.find(search => search.id === addressbookId)
this.loadingUpdate = true
// TODO Properly implement the MOVE request
if (addressbook) {
this.$store.dispatch('moveContactToAddressbook', {
// we need to use the store contact, not the local contact
// using this.contact and not this.localContact
contact: this.contact,
addressbook
}).then(() => {
this.updateContact()
this.loadingUpdate = false
})
}
},
/**
* Refresh the data of a contact
*/
refreshContact() {
this.$store.dispatch('fetchFullContact', { contact: this.contact, etag: this.conflict })
.then(() => {
this.contact.conflict = false
})
}
}
}
</script>