feat: Ability to edit a contact (#1092)

Ability to edit contact information in conversation sidebar

Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Pranav Raj S 2020-08-23 00:05:07 +05:30 committed by GitHub
parent ec6cd4bbba
commit 8cf05f1d9f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 709 additions and 133 deletions

View file

@ -20,6 +20,11 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def update def update
@contact.update!(contact_update_params) @contact.update!(contact_update_params)
rescue ActiveRecord::RecordInvalid => e
render json: {
message: e.record.errors.full_messages.join(', '),
contact: Contact.find_by(email: contact_params[:email])
}, status: :unprocessable_entity
end end
def search def search
@ -43,7 +48,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end end
def contact_params def contact_params
params.require(:contact).permit(:name, :email, :phone_number, custom_attributes: {}) params.require(:contact).permit(:name, :email, :phone_number, additional_attributes: {}, custom_attributes: {})
end end
def contact_custom_attributes def contact_custom_attributes

View file

@ -37,3 +37,9 @@ code {
padding: $space-two; padding: $space-two;
} }
} }
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View file

@ -347,7 +347,7 @@ $helptext-color: $header-color;
$helptext-font-size: $font-size-small; $helptext-font-size: $font-size-small;
$helptext-font-style: italic; $helptext-font-style: italic;
$input-prefix-color: $header-color; $input-prefix-color: $header-color;
$input-prefix-background: $light-gray; $input-prefix-background: var(--b-100);
$input-prefix-border: 1px solid $color-border; $input-prefix-border: 1px solid $color-border;
$input-prefix-padding: 1rem; $input-prefix-padding: 1rem;
$form-label-color: $header-color; $form-label-color: $header-color;

View file

@ -55,5 +55,17 @@
input, input,
textarea, textarea,
select { select {
border-radius: 4px !important; border-radius: var(--space-smaller) !important;
}
.input-group {
.input-group-label:first-child {
border-bottom-left-radius: var(--space-smaller);
border-top-left-radius: var(--space-smaller);
}
.input-group-field {
border-bottom-left-radius: 0 !important;
border-top-left-radius: 0 !important;
}
} }

View file

@ -0,0 +1,43 @@
<template>
<button :type="type" class="button nice" :class="variant" @click="onClick">
<i
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass + ' ' + icon"
/>
<spinner v-if="isLoading" />
<slot></slot>
</button>
</template>
<script>
export default {
props: {
isLoading: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
},
buttonIconClass: {
type: String,
default: '',
},
type: {
type: String,
default: 'button',
},
variant: {
type: String,
default: 'primary',
},
},
methods: {
onClick(e) {
this.$emit('click', e);
},
},
};
</script>

View file

@ -87,11 +87,3 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped>
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View file

@ -45,7 +45,6 @@
<span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span> <span slot="noResult">{{ $t('AGENT_MGMT.SEARCH.NO_RESULTS') }}</span>
</multiselect> </multiselect>
</div> </div>
<more-actions :conversation-id="currentChat.id" /> <more-actions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@
:status="currentChat.status" :status="currentChat.status"
/> />
<woot-button <woot-button
class="success hollow more--button" class="success more--button"
icon="ion-more" icon="ion-more"
:class="buttonClass" :class="buttonClass"
@click="toggleConversationActions" @click="toggleConversationActions"
@ -94,7 +94,7 @@ export default {
.more--button { .more--button {
align-items: center; align-items: center;
display: flex; display: flex;
margin-left: var(--space-small); margin-left: var(--space-smaller);
padding: var(--space-small); padding: var(--space-small);
} }

View file

@ -25,6 +25,7 @@ export default {
default: '', default: '',
}, },
}, },
watch: {},
methods: { methods: {
handleImageUpload(event) { handleImageUpload(event) {
const [file] = event.target.files; const [file] = event.target.files;

View file

@ -1,5 +1,10 @@
{ {
"CONTACT_PANEL": { "CONTACT_PANEL": {
"NOT_AVAILABLE": "Not Available",
"EMAIL_ADDRESS": "Email Address",
"PHONE_NUMBER": "Phone number",
"COMPANY": "Company",
"LOCATION": "Location",
"CONVERSATION_TITLE": "Conversation Details", "CONVERSATION_TITLE": "Conversation Details",
"BROWSER": "Browser", "BROWSER": "Browser",
"OS": "Operating System", "OS": "Operating System",
@ -30,5 +35,62 @@
"MUTED_SUCCESS": "This conversation is muted for 6 hours", "MUTED_SUCCESS": "This conversation is muted for 6 hours",
"SEND_TRANSCRIPT": "Send Transcript", "SEND_TRANSCRIPT": "Send Transcript",
"EDIT_LABEL": "Edit" "EDIT_LABEL": "Edit"
},
"EDIT_CONTACT": {
"BUTTON_LABEL": "Edit Contact",
"TITLE": "Edit contact",
"DESC": "Edit contact details",
"FORM": {
"SUBMIT": "Submit",
"CANCEL": "Cancel",
"AVATAR": {
"LABEL": "Contact Avatar"
},
"NAME": {
"PLACEHOLDER": "Enter the full name of the contact",
"LABEL": "Full Name"
},
"BIO": {
"PLACEHOLDER": "Enter the bio of the contact",
"LABEL": "Bio"
},
"EMAIL_ADDRESS": {
"PLACEHOLDER": "Enter the email address of the contact",
"LABEL": "Email Address"
},
"PHONE_NUMBER": {
"PLACEHOLDER": "Enter the phone number of the contact",
"LABEL": "Phone Number"
},
"LOCATION": {
"PLACEHOLDER": "Enter the location of the contact",
"LABEL": "Location"
},
"COMPANY_NAME": {
"PLACEHOLDER": "Enter the company name",
"LABEL": "Company Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
"PLACEHOLDER": "Enter the Facebook username",
"LABEL": "Facebook"
},
"TWITTER": {
"PLACEHOLDER": "Enter the Twitter username",
"LABEL": "Twitter"
},
"LINKEDIN": {
"PLACEHOLDER": "Enter the LinkedIn username",
"LABEL": "LinkedIn"
},
"GITHUB": {
"PLACEHOLDER": "Enter the Github username",
"LABEL": "Github"
}
}
},
"SUCCESS_MESSAGE": "Updated contact successfully",
"CONTACT_ALREADY_EXIST": "This email address is in use for another contact.",
"ERROR_MESSAGE": "There was an error updating the contact, please try again"
} }
} }

View file

@ -1,63 +1,9 @@
<template> <template>
<div class="medium-3 bg-white contact--panel"> <div class="medium-3 bg-white contact--panel">
<div class="contact--profile"> <span class="close-button" @click="onPanelToggle">
<span class="close-button" @click="onPanelToggle"> <i class="ion-chevron-right" />
<i class="ion-chevron-right" /> </span>
</span> <contact-info :contact="contact" :channel-type="channelType" />
<div class="contact--info">
<thumbnail
:src="contact.thumbnail"
size="64px"
:badge="channelType"
:username="contact.name"
:status="contact.availability_status"
/>
<div class="contact--details">
<div class="contact--name">
{{ contact.name }}
</div>
<a
v-if="contact.email"
:href="`mailto:${contact.email}`"
class="contact--email"
>
{{ contact.email }}
</a>
<a
v-if="contact.phone_number"
:href="`tel:${contact.phone_number}`"
class="contact--email"
>
{{ contact.phone_number }}
</a>
<div
v-if="
contact.additional_attributes &&
contact.additional_attributes.screen_name
"
class="contact--location"
>
{{ `@${contact.additional_attributes.screen_name}` }}
</div>
<div class="contact--location">
{{ contact.location }}
</div>
</div>
</div>
<div v-if="contact.bio" class="contact--bio">
{{ contact.bio }}
</div>
<div
v-if="
contact.additional_attributes &&
contact.additional_attributes.description
"
class="contact--bio"
>
{{ contact.additional_attributes.description }}
</div>
</div>
<div v-if="browser.browser_name" class="conversation--details"> <div v-if="browser.browser_name" class="conversation--details">
<contact-details-item <contact-details-item
v-if="browser.browser_name" v-if="browser.browser_name"
@ -99,9 +45,9 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import ContactConversations from './ContactConversations.vue'; import ContactConversations from './ContactConversations.vue';
import ContactDetailsItem from './ContactDetailsItem.vue'; import ContactDetailsItem from './ContactDetailsItem.vue';
import ContactInfo from './contact/ContactInfo';
import ConversationLabels from './labels/LabelBox.vue'; import ConversationLabels from './labels/LabelBox.vue';
import ContactCustomAttributes from './ContactCustomAttributes'; import ContactCustomAttributes from './ContactCustomAttributes';
@ -110,8 +56,8 @@ export default {
ContactCustomAttributes, ContactCustomAttributes,
ContactConversations, ContactConversations,
ContactDetailsItem, ContactDetailsItem,
ContactInfo,
ConversationLabels, ConversationLabels,
Thumbnail,
}, },
props: { props: {
conversationId: { conversationId: {
@ -210,7 +156,11 @@ export default {
overflow-y: auto; overflow-y: auto;
overflow: auto; overflow: auto;
position: relative; position: relative;
padding: $space-normal; padding: $space-one;
i {
margin-right: $space-smaller;
}
} }
.close-button { .close-button {
@ -221,57 +171,9 @@ export default {
color: $color-heading; color: $color-heading;
} }
.contact--profile {
align-items: center;
padding: $space-medium 0 $space-one;
.user-thumbnail-box {
margin-right: $space-normal;
}
}
.contact--details {
margin-top: $space-small;
p {
margin-bottom: 0;
}
}
.contact--info {
align-items: center;
display: flex;
flex-direction: column;
text-align: center;
}
.contact--name {
@include text-ellipsis;
text-transform: capitalize;
font-weight: $font-weight-bold;
font-size: $font-size-default;
}
.contact--email {
@include text-ellipsis;
color: $color-gray;
display: block;
line-height: $space-medium;
&:hover {
color: $color-woot;
}
}
.contact--bio {
margin-top: $space-normal;
}
.conversation--details { .conversation--details {
border-top: 1px solid $color-border-light; border-top: 1px solid $color-border-light;
padding: $space-large $space-normal; padding: $space-normal;
} }
.conversation--labels { .conversation--labels {

View file

@ -0,0 +1,157 @@
<template>
<div class="contact--profile">
<div class="contact--info">
<thumbnail
:src="contact.thumbnail"
size="48px"
:badge="channelType"
:username="contact.name"
:status="contact.availability_status"
/>
<div class="contact--details">
<div class="contact--name">
{{ contact.name }}
</div>
<div v-if="additionalAttibutes.description" class="contact--bio">
{{ additionalAttibutes.description }}
</div>
<social-icons :social-profiles="socialProfiles" />
<div class="contact--metadata">
<contact-info-row
:href="contact.email ? `mailto:${contact.email}` : ''"
:value="contact.email"
icon="ion-email"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
/>
<contact-info-row
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
:value="contact.phone_number"
icon="ion-ios-telephone"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
/>
<contact-info-row
:value="additionalAttibutes.location"
icon="ion-map"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<contact-info-row
:value="additionalAttibutes.company_name"
icon="ion-briefcase"
:title="$t('CONTACT_PANEL.COMPANY')"
/>
</div>
</div>
<woot-button
class="expanded"
variant="hollow primary small"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
<edit-contact
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
</div>
</div>
</template>
<script>
import ContactInfoRow from './ContactInfoRow';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons';
import EditContact from './EditContact';
export default {
components: {
ContactInfoRow,
EditContact,
Thumbnail,
SocialIcons,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
channelType: {
type: String,
default: '',
},
},
data() {
return {
showEditModal: false,
};
},
computed: {
additionalAttibutes() {
return this.contact.additional_attributes || {};
},
socialProfiles() {
const {
social_profiles: socialProfiles,
screen_name: twitterScreenName,
} = this.additionalAttibutes;
return { twitter: twitterScreenName, ...(socialProfiles || {}) };
},
},
methods: {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/mixins';
.contact--profile {
align-items: flex-start;
padding: $space-normal;
.user-thumbnail-box {
margin-right: $space-normal;
}
}
.contact--details {
margin-top: $space-small;
p {
margin-bottom: 0;
}
}
.contact--info {
align-items: flex-start;
display: flex;
flex-direction: column;
text-align: left;
}
.contact--name {
@include text-ellipsis;
text-transform: capitalize;
font-weight: $font-weight-bold;
font-size: $font-size-default;
}
.contact--bio {
margin: $space-small 0 0;
}
.contact--metadata {
margin: $space-small 0 $space-normal;
}
.social--icons {
i {
font-size: $font-weight-normal;
}
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<div class="contact-info--row">
<a v-if="href" :href="href" class="contact-info--details">
<i :class="icon" class="contact-info--icon" />
<span v-if="value" class="text-truncate">{{ value }}</span>
<span v-else class="text-muted">{{
$t('CONTACT_PANEL.NOT_AVAILABLE')
}}</span>
</a>
<div v-else class="contact-info--details">
<i :class="icon" class="contact-info--icon" />
<span v-if="value" class="text-truncate">{{ value }}</span>
<span v-else class="text-muted">{{
$t('CONTACT_PANEL.NOT_AVAILABLE')
}}</span>
</div>
</div>
</template>
<script>
export default {
props: {
href: {
type: String,
default: '',
},
icon: {
type: String,
required: true,
},
value: {
type: String,
default: '',
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.contact-info--row {
.contact-info--icon {
font-size: $font-size-default;
min-width: $space-medium;
}
}
.contact-info--details {
display: flex;
align-items: center;
margin-bottom: $space-smaller;
color: $color-body;
&.a {
&:hover {
text-decoration: underline;
}
}
}
</style>

View file

@ -0,0 +1,225 @@
<template>
<woot-modal :show.sync="show" :on-close="onCancel">
<div class="column content-box">
<woot-modal-header
:header-title="
`${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
"
:header-content="$t('EDIT_CONTACT.DESC')"
/>
<form class="edit-contact--form" @submit.prevent="onSubmit">
<div class="row">
<div class="medium-9 columns">
<label :class="{ error: $v.name.$error }">
{{ $t('EDIT_CONTACT.FORM.NAME.LABEL') }}
<input
v-model.trim="name"
type="text"
:placeholder="$t('EDIT_CONTACT.FORM.NAME.PLACEHOLDER')"
@input="$v.name.$touch"
/>
</label>
<label :class="{ error: $v.email.$error }">
{{ $t('EDIT_CONTACT.FORM.EMAIL_ADDRESS.LABEL') }}
<input
v-model.trim="email"
type="text"
:placeholder="$t('EDIT_CONTACT.FORM.EMAIL_ADDRESS.PLACEHOLDER')"
@input="$v.email.$touch"
/>
</label>
</div>
</div>
<div class="medium-12 columns">
<label :class="{ error: $v.description.$error }">
{{ $t('EDIT_CONTACT.FORM.BIO.LABEL') }}
<textarea
v-model.trim="description"
type="text"
:placeholder="$t('EDIT_CONTACT.FORM.BIO.PLACEHOLDER')"
@input="$v.description.$touch"
/>
</label>
</div>
<div class="row">
<woot-input
v-model.trim="phoneNumber"
class="medium-6 columns"
:label="$t('EDIT_CONTACT.FORM.PHONE_NUMBER.LABEL')"
:placeholder="$t('EDIT_CONTACT.FORM.PHONE_NUMBER.PLACEHOLDER')"
/>
<woot-input
v-model.trim="location"
class="medium-6 columns"
:label="$t('EDIT_CONTACT.FORM.LOCATION.LABEL')"
:placeholder="$t('EDIT_CONTACT.FORM.LOCATION.PLACEHOLDER')"
/>
</div>
<woot-input
v-model.trim="companyName"
class="medium-6 columns"
:label="$t('EDIT_CONTACT.FORM.COMPANY_NAME.LABEL')"
:placeholder="$t('EDIT_CONTACT.FORM.COMPANY_NAME.PLACEHOLDER')"
/>
<div class="medium-12 columns">
<label>
Social Profiles
</label>
<div
v-for="socialProfile in socialProfileKeys"
:key="socialProfile.key"
class="input-group"
>
<span class="input-group-label">{{ socialProfile.prefixURL }}</span>
<input
v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field"
type="text"
/>
</div>
</div>
<div class="modal-footer">
<div class="medium-12 columns">
<woot-submit-button :button-text="$t('EDIT_CONTACT.FORM.SUBMIT')" />
<button class="button clear" @click.prevent="onCancel">
{{ $t('EDIT_CONTACT.FORM.CANCEL') }}
</button>
</div>
</div>
</form>
</div>
</woot-modal>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import { DuplicateContactException } from 'shared/helpers/CustomErrors';
import { required } from 'vuelidate/lib/validators';
export default {
mixins: [alertMixin],
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
data() {
return {
hasADuplicateContact: false,
duplicateContact: {},
companyName: '',
description: '',
email: '',
location: '',
name: '',
phoneNumber: '',
socialProfileUserNames: {
facebook: '',
twitter: '',
linkedin: '',
},
socialProfileKeys: [
{ key: 'facebook', prefixURL: 'https://facebook.com/' },
{ key: 'twitter', prefixURL: 'https://twitter.com/' },
{ key: 'linkedin', prefixURL: 'https://linkedin.com/' },
],
};
},
validations: {
name: {
required,
},
description: {},
email: {},
companyName: {},
phoneNumber: {},
location: {},
bio: {},
},
watch: {
contact() {
this.setContactObject();
},
},
methods: {
onCancel() {
this.$emit('cancel');
},
setContactObject() {
const { email: email, phone_number: phoneNumber, name } = this.contact;
const additionalAttributes = this.contact.additional_attributes || {};
this.name = name || '';
this.email = email || '';
this.phoneNumber = phoneNumber || '';
this.location = additionalAttributes.location || '';
this.companyName = additionalAttributes.company_name || '';
this.description = additionalAttributes.description || '';
const {
social_profiles: socialProfiles = {},
screen_name: twitterScreenName,
} = additionalAttributes;
this.socialProfileUserNames = {
twitter: socialProfiles.twitter || twitterScreenName || '',
facebook: socialProfiles.facebook || '',
linkedin: socialProfiles.linkedin || '',
};
},
getContactObject() {
return {
id: this.contact.id,
name: this.name,
email: this.email,
phone_number: this.phoneNumber,
additional_attributes: {
...this.contact.additional_attributes,
description: this.description,
location: this.location,
company_name: this.companyName,
social_profiles: this.socialProfileUserNames,
},
};
},
resetDuplicate() {
this.hasADuplicateContact = false;
this.duplicateContact = {};
},
async onSubmit() {
this.resetDuplicate();
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
try {
await this.$store.dispatch('contacts/update', this.getContactObject());
this.showAlert(this.$t('EDIT_CONTACT.SUCCESS_MESSAGE'));
} catch (error) {
if (error instanceof DuplicateContactException) {
this.hasADuplicateContact = true;
this.duplicateContact = error.data;
this.showAlert(this.$t('EDIT_CONTACT.CONTACT_ALREADY_EXIST'));
} else {
this.showAlert(this.$t('EDIT_CONTACT.ERROR_MESSAGE'));
}
}
},
},
};
</script>
<style scoped lang="scss">
.edit-contact--form {
padding: var(--space-normal) var(--space-large) var(--space-large);
.columns {
padding: 0 var(--space-smaller);
}
}
</style>

View file

@ -0,0 +1,56 @@
<template>
<div v-if="availableProfiles.length" class="social--icons">
<a
v-for="profile in availableProfiles"
:key="profile.key"
:href="`${profile.link}${socialProfiles[profile.key]}`"
target="_blank"
rel="noopener noreferrer nofollow"
class="contact--social-icon"
>
<i :class="`ion-social-${profile.icon}`" />
</a>
</div>
</template>
<script>
export default {
props: {
socialProfiles: {
type: Object,
default: () => ({}),
},
},
data() {
return {
socialMediaLinks: [
{ key: 'facebook', icon: 'facebook', link: 'https://facebook.com/' },
{ key: 'twitter', icon: 'twitter', link: 'https://twitter.com/' },
{ key: 'linkedin', icon: 'linkedin', link: 'https://linkedin.com/' },
{ key: 'github', icon: 'github', link: 'https://github.com/' },
],
};
},
computed: {
availableProfiles() {
return this.socialMediaLinks.filter(
mediaLink => !!this.socialProfiles[mediaLink.key]
);
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
.contact--social-icon {
font-size: $font-size-medium;
padding-right: $space-slab;
color: $color-body;
}
.social--icons {
margin-top: $space-small;
}
</style>

View file

@ -35,7 +35,6 @@
</template> </template>
<script> <script>
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import AddLabelToConversation from './AddLabelToConversation'; import AddLabelToConversation from './AddLabelToConversation';
import ContactDetailsItem from '../ContactDetailsItem'; import ContactDetailsItem from '../ContactDetailsItem';

View file

@ -42,7 +42,6 @@
<script> <script>
/* eslint no-console: 0 */ /* eslint no-console: 0 */
/* global bus */
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import InboxMembersAPI from '../../../../api/inboxMembers'; import InboxMembersAPI from '../../../../api/inboxMembers';

View file

@ -1,4 +1,4 @@
/* eslint no-param-reassign: 0 */ import { DuplicateContactException } from 'shared/helpers/CustomErrors';
import * as types from '../mutation-types'; import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts'; import ContactAPI from '../../api/contacts';
import Vue from 'vue'; import Vue from 'vue';
@ -56,7 +56,11 @@ export const actions = {
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }); commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
} catch (error) { } catch (error) {
commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }); commit(types.default.SET_CONTACT_UI_FLAG, { isUpdating: false });
throw new Error(error); if (error.response?.data?.contact) {
throw new DuplicateContactException(error.response.data.contact);
} else {
throw new Error(error);
}
} }
}, },

View file

@ -2,6 +2,7 @@ import axios from 'axios';
import { actions } from '../../contacts'; import { actions } from '../../contacts';
import * as types from '../../../mutation-types'; import * as types from '../../../mutation-types';
import contactList from './fixtures'; import contactList from './fixtures';
import { DuplicateContactException } from '../../../../../shared/helpers/CustomErrors';
const commit = jest.fn(); const commit = jest.fn();
global.axios = axios; global.axios = axios;
@ -68,6 +69,24 @@ describe('#actions', () => {
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }], [types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]); ]);
}); });
it('sends correct actions if duplicate contact is found', async () => {
axios.patch.mockRejectedValue({
response: {
data: {
message: 'Incorrect header',
contact: { id: 1, name: 'contact-name' },
},
},
});
await expect(actions.update({ commit }, contactList[0])).rejects.toThrow(
DuplicateContactException
);
expect(commit.mock.calls).toEqual([
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: true }],
[types.default.SET_CONTACT_UI_FLAG, { isUpdating: false }],
]);
});
}); });
describe('#setContact', () => { describe('#setContact', () => {

View file

@ -0,0 +1,7 @@
export class DuplicateContactException extends Error {
constructor(data) {
super('DUPLICATE_CONTACT');
this.data = data;
this.name = 'DuplicateContactException';
}
}

View file

@ -0,0 +1,17 @@
const { DuplicateContactException } = require('../CustomErrors');
describe('DuplicateContactException', () => {
it('returns correct exception', () => {
const exception = new DuplicateContactException({
id: 1,
name: 'contact-name',
email: 'email@example.com',
});
expect(exception.message).toEqual('DUPLICATE_CONTACT');
expect(exception.data).toEqual({
id: 1,
name: 'contact-name',
email: 'email@example.com',
});
});
});

View file

@ -38,7 +38,7 @@ class Contact < ApplicationRecord
has_many :inboxes, through: :contact_inboxes has_many :inboxes, through: :contact_inboxes
has_many :messages, as: :sender, dependent: :destroy has_many :messages, as: :sender, dependent: :destroy
before_validation :downcase_email before_validation :prepare_email_attribute
after_create_commit :dispatch_create_event after_create_commit :dispatch_create_event
after_update_commit :dispatch_update_event after_update_commit :dispatch_update_event
@ -69,9 +69,9 @@ class Contact < ApplicationRecord
} }
end end
private def prepare_email_attribute
# So that the db unique constraint won't throw error when email is ''
def downcase_email self.email = nil if email.blank?
email.downcase! if email.present? email.downcase! if email.present?
end end

View file

@ -125,7 +125,7 @@ RSpec.describe 'Contacts API', type: :request do
describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do describe 'PATCH /api/v1/accounts/{account.id}/contacts/:id' do
let(:custom_attributes) { { test: 'test', test1: 'test1' } } let(:custom_attributes) { { test: 'test', test1: 'test1' } }
let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) } let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) }
let(:valid_params) { { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } } let(:valid_params) { { contact: { name: 'Test Blub', custom_attributes: { test: 'new test', test2: 'test2' } } } }
context 'when it is an unauthenticated user' do context 'when it is an unauthenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@ -162,6 +162,18 @@ RSpec.describe 'Contacts API', type: :request do
expect(response).to have_http_status(:not_found) expect(response).to have_http_status(:not_found)
end end
it 'prevents updating with an existing email' do
other_contact = create(:contact, account: account, email: 'test1@example.com')
patch "/api/v1/accounts/#{account.id}/contacts/#{contact.id}",
headers: admin.create_new_auth_token,
params: valid_params[:contact].merge({ email: other_contact.email }),
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['contact']['id']).to eq(other_contact.id)
end
end end
end end
end end