feat: Create modal to merge two contacts (#2457)

This commit is contained in:
Nithin David Thomas 2021-10-13 18:35:13 +05:30 committed by GitHub
parent 6c3e2a0bd3
commit b33701a666
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 437 additions and 110 deletions

View file

@ -42,7 +42,7 @@ class ContactMergeAction
end
def merge_and_remove_mergee_contact
mergable_attribute_keys = %w[identifier name email phone_number custom_attributes]
mergable_attribute_keys = %w[identifier name email phone_number additional_attributes custom_attributes]
base_contact_attributes = base_contact.attributes.slice(*mergable_attribute_keys).compact_blank
mergee_contact_attributes = mergee_contact.attributes.slice(*mergable_attribute_keys).compact_blank

View file

@ -0,0 +1,18 @@
/* global axios */
import ApiClient from './ApiClient';
class AccountActions extends ApiClient {
constructor() {
super('actions', { accountScoped: true });
}
merge(parentId, childId) {
return axios.post(`${this.url}/contact_merge`, {
base_contact_id: parentId,
mergee_contact_id: childId,
});
}
}
export default new AccountActions();

View file

@ -0,0 +1,23 @@
import accountActionsAPI from '../accountActions';
import ApiClient from '../ApiClient';
import describeWithAPIMock from './apiSpecHelper';
describe('#ContactsAPI', () => {
it('creates correct instance', () => {
expect(accountActionsAPI).toBeInstanceOf(ApiClient);
expect(accountActionsAPI).toHaveProperty('merge');
});
describeWithAPIMock('API calls', context => {
it('#merge', () => {
accountActionsAPI.merge(1, 2);
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/actions/contact_merge',
{
base_contact_id: 1,
mergee_contact_id: 2,
}
);
});
});
});

View file

@ -15,6 +15,10 @@
.multiselect {
margin-bottom: var(--space-normal);
&.multiselect--disabled {
opacity: .8;
}
.multiselect--active {
>.multiselect__tags {
border-color: $color-woot;
@ -209,3 +213,53 @@
flex-shrink: 0;
}
}
.multiselect-wrap--medium {
$multiselect-height: 4.8rem;
.multiselect__tags,
.multiselect__input {
align-items: center;
display: flex;
}
.multiselect__tags,
.multiselect__input,
.multiselect {
background: var(--white);
font-size: var(--font-size-small);
height: $multiselect-height;
min-height: $multiselect-height;
}
.multiselect__input {
height: $multiselect-height - $space-micro;
min-height: $multiselect-height - $space-micro;
}
.multiselect__single {
align-items: center;
display: flex;
font-size: var(--font-size-small);
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__placeholder {
margin: 0;
padding: var(--space-smaller) var(--space-micro);
}
.multiselect__select {
min-height: $multiselect-height;
}
.multiselect--disabled .multiselect__current,
.multiselect--disabled .multiselect__select {
background: transparent;
}
.multiselect__tags-wrap {
flex-shrink: 0;
}
}

View file

@ -32,6 +32,8 @@
"NO_RESULT": "No labels found"
}
},
"MERGE_CONTACT": "Merge contact",
"CONTACT_ACTIONS": "Contact actions",
"MUTE_CONTACT": "Mute Conversation",
"UNMUTE_CONTACT": "Unmute Conversation",
"MUTED_SUCCESS": "This conversation is muted for 6 hours",
@ -242,17 +244,19 @@
},
"MERGE_CONTACTS": {
"TITLE": "Merge contacts",
"DESCRIPTION": "Merge contact is helpful when you have duplicated entries of the same contact. Merging action takes a primary contact and a child contact. After merging, all details in the primary contact will remain the same. If the primary contact doesn't have a field, then the value from the child contact will be used after merging. If a conflict happens, fields in primary contact will remain unaffected, but fields from secondary will be copied to the custom attributes in the primary contact.",
"DESCRIPTION": "Merge contacts to combine two profiles into one, including all attributes and conversations. In case of conflict, the Primary contact s attributes will take precedence.",
"PRIMARY": {
"TITLE": "Primary contact"
"TITLE": "Primary contact",
"HELP_LABEL": "To be kept"
},
"CHILD": {
"TITLE": "Contact to merge",
"PLACEHOLDER": "Choose a contact"
"PLACEHOLDER": "Search for a contact",
"HELP_LABEL": "To be deleted"
},
"SUMMARY": {
"TITLE": "Summary",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong>will be deleted.",
"DELETE_WARNING": "Contact of <strong>%{childContactName}</strong> will be deleted.",
"ATTRIBUTE_WARNING": "Contact details of <strong>%{childContactName}</strong> will be copied to <strong>%{primaryContactName}</strong>."
},
"SEARCH": {
@ -265,7 +269,7 @@
"ERROR": "Select a child contact to merge"
},
"SUCCESS_MESSAGE": "Contact merged successfully",
"ERROR_MESSAGE": "Could not merge contcts, try again!"
"ERROR_MESSAGE": "Could not merge contacts, try again!"
}
}
}

View file

@ -0,0 +1,89 @@
<template>
<woot-modal :show.sync="show" :on-close="onClose">
<woot-modal-header
:header-title="$t('MERGE_CONTACTS.TITLE')"
:header-content="$t('MERGE_CONTACTS.DESCRIPTION')"
/>
<merge-contact
:primary-contact="primaryContact"
:is-searching="isSearching"
:is-merging="uiFlags.isMerging"
:search-results="searchResults"
@search="onContactSearch"
@cancel="onClose"
@submit="onMergeContacts"
/>
</woot-modal>
</template>
<script>
import alertMixin from 'shared/mixins/alertMixin';
import MergeContact from 'dashboard/modules/contact/components/MergeContact';
import ContactAPI from 'dashboard/api/contacts';
import { mapGetters } from 'vuex';
export default {
components: { MergeContact },
mixins: [alertMixin],
props: {
primaryContact: {
type: Object,
required: true,
},
show: {
type: Boolean,
default: false,
},
},
data() {
return {
isSearching: false,
searchResults: [],
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
},
methods: {
onClose() {
this.$emit('close');
},
async onContactSearch(query) {
this.isSearching = true;
this.searchResults = [];
try {
const {
data: { payload },
} = await ContactAPI.search(query);
this.searchResults = payload.filter(
contact => contact.id !== this.primaryContact.id
);
} catch (error) {
this.showAlert(this.$t('MERGE_CONTACTS.SEARCH.ERROR_MESSAGE'));
} finally {
this.isSearching = false;
}
},
async onMergeContacts(childContactId) {
try {
await this.$store.dispatch('contacts/merge', {
childId: childContactId,
parentId: this.primaryContact.id,
});
this.showAlert(this.$t('MERGE_CONTACTS.FORM.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
this.showAlert(this.$t('MERGE_CONTACTS.FORM.ERROR_MESSAGE'));
}
},
},
};
</script>
<style lang="scss" scoped></style>

View file

@ -1,9 +1,24 @@
<template>
<div class="option-item--user">
<thumbnail :src="thumbnail" size="24px" :username="name" />
<span class="option__title">
{{ name }}
</span>
<thumbnail :src="thumbnail" size="28px" :username="name" />
<div class="option__user-data">
<h5 class="option__title">
{{ name }}
<span v-if="identifier" class="user-identifier">
( id: {{ identifier }} )
</span>
</h5>
<p class="option__body">
<span v-if="email" class="email-icon-wrap">
<i class="icon ion-email" />{{ email }}
</span>
<span v-if="phoneNumber" class="phone-icon-wrap">
<i class="icon ion-ios-telephone" />
{{ phoneNumber }}
</span>
<span v-if="!phoneNumber && !email">{{ '---' }}</span>
</p>
</div>
</div>
</template>
@ -23,6 +38,18 @@ export default {
type: String,
default: '',
},
email: {
type: String,
default: '',
},
phoneNumber: {
type: String,
default: '',
},
identifier: {
type: String,
default: '',
},
},
};
</script>
@ -30,5 +57,49 @@ export default {
<style lang="scss" scoped>
.option-item--user {
display: flex;
align-items: center;
}
.user-identifier {
font-size: var(--font-size-mini);
margin-left: var(--space-micro);
color: var(--s-700);
}
.option__user-data {
display: flex;
flex-direction: column;
flex-grow: 1;
margin-left: var(--space-small);
}
.option__body,
.option__title {
display: flex;
align-items: center;
justify-content: flex-start;
line-height: 1.2;
font-size: var(--font-size-small);
}
.option__body .icon {
position: relative;
top: 1px;
margin-right: var(--space-micro);
}
.option__title {
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-micro);
}
.option__body {
font-size: var(--font-size-mini);
color: var(--s-700);
}
.email-icon-wrap {
margin-right: var(--space-normal);
}
.option__user-data .option__body {
> .phone-icon-wrap,
> .email-icon-wrap {
width: auto;
}
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<div class="contact-fields">
<h3 class="block-title title">Contact fields</h3>
<h3 class="block-title title">{{ $t('CONTACTS_PAGE.FIELDS') }}</h3>
<attribute
:label="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
icon="ion-email"

View file

@ -1,9 +1,15 @@
<template>
<form @submit.prevent="onSubmit">
<div class="merge-contacts">
<div class="multiselect-wrap--small">
<div class="multiselect-wrap--medium">
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.PRIMARY.TITLE') }}
<woot-label
:title="$t('MERGE_CONTACTS.PRIMARY.HELP_LABEL')"
color-scheme="success"
small
class="label--merge-warning"
></woot-label>
</label>
<multiselect
:value="primaryContact"
@ -17,6 +23,9 @@
<contact-dropdown-item
:thumbnail="props.option.thumbnail"
:name="props.option.name"
:identifier="props.option.id"
:email="props.option.email"
:phone-number="props.option.phoneNumber"
/>
</template>
</multiselect>
@ -27,11 +36,17 @@
<i class="ion-ios-arrow-up up" />
</div>
<div
class="child-contact multiselect-wrap--small"
class="child-contact multiselect-wrap--medium"
:class="{ error: $v.childContact.$error }"
>
<label class="multiselect__label">
{{ $t('MERGE_CONTACTS.CHILD.TITLE') }}
{{ $t('MERGE_CONTACTS.CHILD.TITLE')
}}<woot-label
:title="$t('MERGE_CONTACTS.CHILD.HELP_LABEL')"
color-scheme="alert"
small
class="label--merge-warning"
></woot-label>
</label>
<multiselect
v-model="childContact"
@ -51,7 +66,19 @@
<template slot="singleLabel" slot-scope="props">
<contact-dropdown-item
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<template slot="option" slot-scope="props">
<contact-dropdown-item
:thumbnail="props.option.thumbnail"
:identifier="props.option.id"
:name="props.option.name"
:email="props.option.email"
:phone-number="props.option.phone_number"
/>
</template>
<span slot="noResult">
@ -190,12 +217,6 @@ export default {
left: var(--space-normal);
}
::v-deep .multiselect__tags .option__title {
display: inline-flex;
align-items: center;
margin-left: var(--space-small);
}
.footer {
margin-top: var(--space-medium);
display: flex;
@ -206,4 +227,8 @@ export default {
.error .message {
margin-top: 0;
}
.label--merge-warning {
margin-left: var(--space-small);
}
</style>

View file

@ -5,7 +5,7 @@
</h5>
<ul class="summary-items">
<li>
<span></span>
<span class="bullet"></span>
<span
v-html="
$t('MERGE_CONTACTS.SUMMARY.DELETE_WARNING', {
@ -15,7 +15,7 @@
/>
</li>
<li>
<span></span>
<span class="bullet"></span>
<span
v-html="
$t('MERGE_CONTACTS.SUMMARY.ATTRIBUTE_WARNING', {
@ -59,4 +59,9 @@ export default {
margin-bottom: var(--space-smaller);
}
}
.bullet {
display: inline-block;
margin-right: var(--space-smaller);
}
</style>

View file

@ -91,9 +91,9 @@ export default {
.close-button {
position: absolute;
right: var(--space-normal);
top: var(--space-slab);
top: 3.6rem;
font-size: var(--font-size-big);
color: var(--color-heading);
color: var(--s-500);
.close-icon {
margin-right: var(--space-smaller);

View file

@ -222,8 +222,9 @@ export default {
return this.additionalAttributes.initiated_at;
},
browserName() {
return `${this.browser.browser_name || ''} ${this.browser
.browser_version || ''}`;
return `${this.browser.browser_name || ''} ${
this.browser.browser_version || ''
}`;
},
contactAdditionalAttributes() {
return this.contact.additional_attributes || {};
@ -247,10 +248,8 @@ export default {
return `${cityAndCountry} ${countryFlag}`;
},
platformName() {
const {
platform_name: platformName,
platform_version: platformVersion,
} = this.browser;
const { platform_name: platformName, platform_version: platformVersion } =
this.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
channelType() {
@ -421,10 +420,11 @@ export default {
.close-button {
position: absolute;
right: $space-normal;
top: $space-slab;
right: $space-two;
top: $space-slab + $space-two;
font-size: $font-size-default;
color: $color-heading;
z-index: 9999;
}
.conversation--labels {

View file

@ -48,59 +48,49 @@
/>
</div>
</div>
<div v-if="!showNewMessage">
<div>
<woot-button
class="edit-contact"
variant="link"
size="small"
@click="toggleEditModal"
>
{{ $t('EDIT_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
<div v-if="isAdmin">
<woot-button
class="delete-contact"
variant="link"
size="small"
color-scheme="alert"
@click="toggleDeleteModal"
:disabled="uiFlags.isDeleting"
>
{{ $t('DELETE_CONTACT.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
<div v-else>
<div class="contact-actions">
<woot-button
v-tooltip="$t('CONTACT_PANEL.NEW_MESSAGE')"
class="new-message"
icon="ion-chatboxes"
size="small expanded"
@click="toggleConversationModal"
/>
<woot-button
v-tooltip="$t('EDIT_CONTACT.BUTTON_LABEL')"
class="edit-contact"
icon="ion-edit"
variant="smooth"
size="small expanded"
@click="toggleEditModal"
/>
<woot-button
v-if="isAdmin"
v-tooltip="$t('DELETE_CONTACT.BUTTON_LABEL')"
class="delete-contact"
icon="ion-trash-a"
variant="hollow"
size="small expanded"
color-scheme="alert"
@click="toggleDeleteModal"
:disabled="uiFlags.isDeleting"
/>
</div>
<div class="contact-actions">
<woot-button
v-if="showNewMessage"
v-tooltip="$t('CONTACT_PANEL.NEW_MESSAGE')"
title="$t('CONTACT_PANEL.NEW_MESSAGE')"
class="new-message"
icon="ion-chatboxes"
size="small expanded"
@click="toggleConversationModal"
/>
<woot-button
v-tooltip="$t('EDIT_CONTACT.BUTTON_LABEL')"
title="$t('EDIT_CONTACT.BUTTON_LABEL')"
class="edit-contact"
icon="ion-edit"
variant="smooth"
size="small expanded"
@click="toggleEditModal"
/>
<woot-button
v-if="isAdmin"
v-tooltip="$t('CONTACT_PANEL.MERGE_CONTACT')"
title="$t('CONTACT_PANEL.MERGE_CONTACT')"
class="merge-contact"
icon="ion-merge"
variant="smooth"
size="small expanded"
color-scheme="secondary"
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<woot-button
v-if="isAdmin"
v-tooltip="$t('DELETE_CONTACT.BUTTON_LABEL')"
title="$t('DELETE_CONTACT.BUTTON_LABEL')"
class="delete-contact"
icon="ion-trash-a"
variant="smooth"
size="small expanded"
color-scheme="alert"
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
/>
</div>
<edit-contact
v-if="showEditModal"
@ -114,6 +104,12 @@
:contact="contact"
@cancel="toggleConversationModal"
/>
<contact-merge-modal
v-if="showMergeModal"
:primary-contact="contact"
:show="showMergeModal"
@close="toggleMergeModal"
/>
</div>
<woot-confirm-delete-modal
v-if="showDeleteModal"
@ -130,11 +126,14 @@
</div>
</template>
<script>
import { mixin as clickaway } from 'vue-clickaway';
import ContactInfoRow from './ContactInfoRow';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import SocialIcons from './SocialIcons';
import EditContact from './EditContact';
import NewConversation from './NewConversation';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal';
import alertMixin from 'shared/mixins/alertMixin';
import adminMixin from '../../../../mixins/isAdmin';
import { mapGetters } from 'vuex';
@ -146,8 +145,9 @@ export default {
Thumbnail,
SocialIcons,
NewConversation,
ContactMergeModal,
},
mixins: [alertMixin, adminMixin],
mixins: [alertMixin, adminMixin, clickaway],
props: {
contact: {
type: Object,
@ -166,6 +166,7 @@ export default {
return {
showEditModal: false,
showConversationModal: false,
showMergeModal: false,
showDeleteModal: false,
};
},
@ -201,6 +202,9 @@ export default {
},
},
methods: {
toggleMergeModal() {
this.showMergeModal = !this.showMergeModal;
},
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
@ -232,24 +236,26 @@ export default {
);
}
},
openMergeModal() {
this.toggleMergeModal();
},
},
};
</script>
<style scoped lang="scss">
@import '~dashboard/assets/scss/variables';
@import '~dashboard/assets/scss/mixins';
.contact--profile {
position: relative;
align-items: flex-start;
padding: var(--space-normal);
.user-thumbnail-box {
margin-right: $space-normal;
margin-right: var(--space-normal);
}
}
.contact--details {
margin-top: $space-small;
margin-top: var(--space-small);
width: 100%;
}
@ -270,31 +276,25 @@ export default {
margin-top: var(--space-small);
}
.edit-contact {
margin-left: var(--space-medium);
}
.delete-contact {
margin-left: var(--space-medium);
}
.contact-actions {
display: flex;
align-items: center;
width: 100%;
.new-message {
font-size: var(--font-size-medium);
}
.edit-contact {
margin-left: var(--space-small);
font-size: var(--font-size-medium);
}
.new-message,
.edit-contact,
.merge-contact,
.delete-contact {
margin-left: var(--space-small);
font-size: var(--font-size-medium);
margin-right: var(--space-small);
}
}
.merege-summary--card {
padding: var(--space-normal);
}
.button--contact-menu {
position: absolute;
right: var(--space-normal);
top: 0;
}
</style>

View file

@ -4,6 +4,7 @@ import {
} from 'shared/helpers/CustomErrors';
import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts';
import AccountActionsAPI from '../../../api/accountActions';
export const actions = {
search: async ({ commit }, { search, page, sortAttr, label }) => {
@ -137,6 +138,18 @@ export const actions = {
commit(types.SET_CONTACT_ITEM, data);
},
merge: async ({ commit }, { childId, parentId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isMerging: true });
try {
const response = await AccountActionsAPI.merge(parentId, childId);
commit(types.SET_CONTACT_ITEM, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CONTACT_UI_FLAG, { isMerging: false });
}
},
deleteContactThroughConversations: ({ commit }, id) => {
commit(types.DELETE_CONTACT, id);
commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true });

View file

@ -13,6 +13,7 @@ const state = {
isFetchingItem: false,
isFetchingInboxes: false,
isUpdating: false,
isMerging: false,
isDeleting: false,
},
sortOrder: [],

View file

@ -168,6 +168,30 @@ describe('#actions', () => {
});
});
describe('#merge', () => {
it('sends correct mutations if API is success', async () => {
axios.post.mockResolvedValue({
data: contactList[0],
});
await actions.merge({ commit }, { childId: 0, parentId: 1 });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isMerging: true }],
[types.SET_CONTACT_ITEM, contactList[0]],
[types.SET_CONTACT_UI_FLAG, { isMerging: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.merge({ commit }, { childId: 0, parentId: 1 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isMerging: true }],
[types.SET_CONTACT_UI_FLAG, { isMerging: false }],
]);
});
});
describe('#deleteContactThroughConversations', () => {
it('returns correct mutations', () => {
actions.deleteContactThroughConversations({ commit }, contactList[0].id);