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:
parent
ec6cd4bbba
commit
8cf05f1d9f
23 changed files with 709 additions and 133 deletions
|
@ -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
|
||||||
|
|
|
@ -37,3 +37,9 @@ code {
|
||||||
padding: $space-two;
|
padding: $space-two;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-truncate {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
43
app/javascript/dashboard/components/buttons/Button.vue
Normal file
43
app/javascript/dashboard/components/buttons/Button.vue
Normal 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>
|
|
@ -87,11 +87,3 @@ export default {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.text-truncate {
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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';
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
7
app/javascript/shared/helpers/CustomErrors.js
Normal file
7
app/javascript/shared/helpers/CustomErrors.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export class DuplicateContactException extends Error {
|
||||||
|
constructor(data) {
|
||||||
|
super('DUPLICATE_CONTACT');
|
||||||
|
this.data = data;
|
||||||
|
this.name = 'DuplicateContactException';
|
||||||
|
}
|
||||||
|
}
|
17
app/javascript/shared/helpers/specs/CustomErrors.spec.js
Normal file
17
app/javascript/shared/helpers/specs/CustomErrors.spec.js
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue