feat: Render contact custom attributes in contact/conversation sidebar (#3310)
This commit is contained in:
parent
e12edb51a2
commit
76370267f3
33 changed files with 416 additions and 124 deletions
|
@ -14,6 +14,7 @@ Metrics/ClassLength:
|
|||
- 'app/mailers/conversation_reply_mailer.rb'
|
||||
- 'app/models/message.rb'
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
|
|
|
@ -12,7 +12,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index, :active, :search]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes]
|
||||
before_action :fetch_contact, only: [:show, :update, :destroy, :contactable_inboxes, :destroy_custom_attributes]
|
||||
before_action :set_include_contact_inboxes, only: [:index, :search]
|
||||
|
||||
def index
|
||||
|
@ -64,6 +64,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
|||
@contactable_inboxes = @all_contactable_inboxes.select { |contactable_inbox| policy(contactable_inbox[:inbox]).show? }
|
||||
end
|
||||
|
||||
# TODO : refactor this method into dedicated contacts/custom_attributes controller class and routes
|
||||
def destroy_custom_attributes
|
||||
@contact.custom_attributes = @contact.custom_attributes.excluding(params[:custom_attributes])
|
||||
@contact.save!
|
||||
end
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
@contact = Current.account.contacts.new(contact_params)
|
||||
|
|
|
@ -25,9 +25,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
|||
private
|
||||
|
||||
def fetch_custom_attributes_definitions
|
||||
@custom_attribute_definitions = Current.account.custom_attribute_definitions.where(
|
||||
attribute_model: permitted_params[:attribute_model] || DEFAULT_ATTRIBUTE_MODEL
|
||||
)
|
||||
@custom_attribute_definitions = Current.account.custom_attribute_definitions.with_attribute_model(permitted_params[:attribute_model])
|
||||
end
|
||||
|
||||
def fetch_custom_attribute_definition
|
||||
|
|
|
@ -6,8 +6,8 @@ class AttributeAPI extends ApiClient {
|
|||
super('custom_attribute_definitions', { accountScoped: true });
|
||||
}
|
||||
|
||||
getAttributesByModel(modelId) {
|
||||
return axios.get(`${this.url}?attribute_model=${modelId}`);
|
||||
getAttributesByModel() {
|
||||
return axios.get(this.url);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -60,6 +60,12 @@ class ContactAPI extends ApiClient {
|
|||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
}
|
||||
|
||||
destroyCustomAttributes(contactId, customAttributes) {
|
||||
return axios.post(`${this.url}/${contactId}/destroy_custom_attributes`, {
|
||||
custom_attributes: customAttributes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ContactAPI();
|
||||
|
|
|
@ -60,6 +60,16 @@ describe('#ContactsAPI', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('#destroyCustomAttributes', () => {
|
||||
contactAPI.destroyCustomAttributes(1, ['cloudCustomer']);
|
||||
expect(context.axiosMock.post).toHaveBeenCalledWith(
|
||||
'/api/v1/contacts/1/destroy_custom_attributes',
|
||||
{
|
||||
custom_attributes: ['cloudCustomer'],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#importContacts', () => {
|
||||
const file = 'file';
|
||||
contactAPI.importContacts(file);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="contact-attribute">
|
||||
<div class="custom-attribute">
|
||||
<div class="title-wrap">
|
||||
<h4 class="text-block-title title error">
|
||||
<span class="attribute-name" :class="{ error: $v.editedValue.$error }">
|
||||
|
@ -42,7 +42,7 @@
|
|||
{{ value || '---' }}
|
||||
</a>
|
||||
<p v-else class="value">
|
||||
{{ value || '---' }}
|
||||
{{ formattedValue || '---' }}
|
||||
</p>
|
||||
<woot-button
|
||||
v-if="showActions"
|
||||
|
@ -79,9 +79,12 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import format from 'date-fns/format';
|
||||
import { required, url } from 'vuelidate/lib/validators';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
|
@ -89,11 +92,15 @@ export default {
|
|||
showActions: { type: Boolean, default: false },
|
||||
attributeType: { type: String, default: 'text' },
|
||||
attributeKey: { type: String, required: true },
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
editedValue: this.value,
|
||||
editedValue:
|
||||
this.attributeType === 'date'
|
||||
? format(new Date(this.value), DATE_FORMAT)
|
||||
: this.value,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
|
@ -123,6 +130,12 @@ export default {
|
|||
}
|
||||
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
|
||||
},
|
||||
formattedValue() {
|
||||
if (this.attributeType === 'date') {
|
||||
return format(new Date(this.editedValue), 'dd-MM-yyyy');
|
||||
}
|
||||
return this.editedValue;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {
|
||||
|
@ -144,12 +157,17 @@ export default {
|
|||
});
|
||||
},
|
||||
onUpdate() {
|
||||
const updatedValue =
|
||||
this.attributeType === 'date'
|
||||
? format(new Date(this.editedValue), DATE_FORMAT)
|
||||
: this.editedValue;
|
||||
|
||||
this.$v.$touch();
|
||||
if (this.$v.$invalid) {
|
||||
return;
|
||||
}
|
||||
this.isEditing = false;
|
||||
this.$emit('update', this.attributeKey, this.editedValue);
|
||||
this.$emit('update', this.attributeKey, updatedValue);
|
||||
},
|
||||
onDelete() {
|
||||
this.isEditing = false;
|
||||
|
@ -163,7 +181,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.contact-attribute {
|
||||
.custom-attribute {
|
||||
padding: var(--space-slab) var(--space-normal);
|
||||
}
|
||||
|
||||
|
|
|
@ -265,6 +265,7 @@ export default {
|
|||
this.$store.dispatch('inboxes/get');
|
||||
this.$store.dispatch('notifications/unReadCount');
|
||||
this.$store.dispatch('teams/get');
|
||||
this.$store.dispatch('attributes/get');
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"ATTRIBUTES_MGMT": {
|
||||
"HEADER": "Attributes",
|
||||
"HEADER_BTN_TXT": "Add Attribute",
|
||||
"LOADING": "Fetching attributes",
|
||||
"SIDEBAR_TXT": "<p><b>Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Attributes, just click on the <b>Add Attribute.</b> You can also edit or delete an existing Attribute by clicking on the Edit or Delete button.</p>",
|
||||
"HEADER": "Custom Attributes",
|
||||
"HEADER_BTN_TXT": "Add Custom Attribute",
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>",
|
||||
"ADD": {
|
||||
"TITLE": "Add attribute",
|
||||
"TITLE": "Add Custom Attribute",
|
||||
"SUBMIT": "Create",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
"LABEL": "Display Name",
|
||||
"PLACEHOLDER": "Enter attribute display name",
|
||||
"PLACEHOLDER": "Enter custom attribute display name",
|
||||
"ERROR": "Name is required"
|
||||
},
|
||||
"DESC": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Enter attribute description",
|
||||
"PLACEHOLDER": "Enter custom attribute description",
|
||||
"ERROR": "Description is required"
|
||||
},
|
||||
"MODEL": {
|
||||
|
@ -30,34 +30,36 @@
|
|||
"ERROR": "Type is required"
|
||||
},
|
||||
"KEY": {
|
||||
"LABEL": "Key"
|
||||
"LABEL": "Key",
|
||||
"PLACEHOLDER": "Enter custom attribute key",
|
||||
"ERROR": "Key is required"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Attribute added successfully",
|
||||
"ERROR_MESSAGE": "Could not able to create an attribute, Please try again later"
|
||||
"SUCCESS_MESSAGE": "Custom Attribute added successfully",
|
||||
"ERROR_MESSAGE": "Could not able to create a custom attribute, Please try again later"
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Attribute deleted successfully.",
|
||||
"ERROR_MESSAGE": "Couldn't delete the attribute. Try again."
|
||||
"SUCCESS_MESSAGE": "Custom Attribute deleted successfully.",
|
||||
"ERROR_MESSAGE": "Couldn't delete the custom attribute. Try again."
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Are you sure want to delete - %{attributeName}",
|
||||
"PLACE_HOLDER": "Please type {attributeName} to confirm",
|
||||
"MESSAGE": "Deleting will remove the attribute",
|
||||
"MESSAGE": "Deleting will remove the custom attribute",
|
||||
"YES": "Delete ",
|
||||
"NO": "Cancel"
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Edit attribute",
|
||||
"TITLE": "Edit Custom Attribute",
|
||||
"UPDATE_BUTTON_TEXT": "Update",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Attribute updated successfully",
|
||||
"ERROR_MESSAGE": "There was an error updating attribute, please try again"
|
||||
"SUCCESS_MESSAGE": "Custom Attribute updated successfully",
|
||||
"ERROR_MESSAGE": "There was an error updating custom attribute, please try again"
|
||||
}
|
||||
},
|
||||
"TABS": {
|
||||
|
@ -72,8 +74,8 @@
|
|||
"DELETE": "Delete"
|
||||
},
|
||||
"EMPTY_RESULT": {
|
||||
"404": "There are no attributes created",
|
||||
"NOT_FOUND": "There are no attributes configured"
|
||||
"404": "There are no custom attributes created",
|
||||
"NOT_FOUND": "There are no custom attributes configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -263,7 +263,7 @@
|
|||
"PLACEHOLDER": "Eg: 11901 "
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add",
|
||||
"TITLE": "Create new attribute ",
|
||||
"SUCCESS": "Attribute added successfully",
|
||||
"ERROR": "Unable to add attribute. Please try again later"
|
||||
},
|
||||
|
|
|
@ -139,7 +139,7 @@
|
|||
"ACCOUNT_SETTINGS": "Account Settings",
|
||||
"APPLICATIONS": "Applications",
|
||||
"LABELS": "Labels",
|
||||
"ATTRIBUTES": "Attributes",
|
||||
"CUSTOM_ATTRIBUTES": "Custom Attributes",
|
||||
"TEAMS": "Teams",
|
||||
"ALL_CONTACTS": "All Contacts",
|
||||
"TAGGED_WITH": "Tagged with",
|
||||
|
|
|
@ -67,9 +67,11 @@ const settings = accountId => ({
|
|||
},
|
||||
attributes: {
|
||||
icon: 'ion-code',
|
||||
label: 'ATTRIBUTES',
|
||||
label: 'CUSTOM_ATTRIBUTES',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/settings/attributes/list`),
|
||||
toState: frontendURL(
|
||||
`accounts/${accountId}/settings/custom-attributes/list`
|
||||
),
|
||||
toStateName: 'attributes_list',
|
||||
},
|
||||
cannedResponses: {
|
||||
|
|
|
@ -8,31 +8,50 @@ export default {
|
|||
}),
|
||||
attributes() {
|
||||
return this.$store.getters['attributes/getAttributesByModel'](
|
||||
'conversation_attribute'
|
||||
this.attributeType
|
||||
);
|
||||
},
|
||||
customAttributes() {
|
||||
return this.currentChat.custom_attributes || {};
|
||||
if (this.attributeType === 'conversation_attribute')
|
||||
return this.currentChat.custom_attributes || {};
|
||||
return this.contact.custom_attributes || {};
|
||||
},
|
||||
contactIdentifier() {
|
||||
return (
|
||||
this.currentChat.meta?.sender?.id ||
|
||||
this.$route.params.contactId ||
|
||||
this.contactId
|
||||
);
|
||||
},
|
||||
contact() {
|
||||
return this.$store.getters['contacts/getContact'](this.contactIdentifier);
|
||||
},
|
||||
conversationId() {
|
||||
return this.currentChat.id;
|
||||
},
|
||||
// Select only custom attribute which are already defined
|
||||
filteredAttributes() {
|
||||
return Object.keys(this.customAttributes)
|
||||
.filter(key => {
|
||||
return this.attributes.find(item => item.attribute_key === key);
|
||||
})
|
||||
.map(key => {
|
||||
const item = this.attributes.find(
|
||||
attribute => attribute.attribute_key === key
|
||||
);
|
||||
return Object.keys(this.customAttributes).map(key => {
|
||||
const item = this.attributes.find(
|
||||
attribute => attribute.attribute_key === key
|
||||
);
|
||||
if (item) {
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
icon: this.attributeIcon(item.attribute_display_type),
|
||||
};
|
||||
});
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
value: this.customAttributes[key],
|
||||
attribute_description: key,
|
||||
attribute_display_name: key,
|
||||
attribute_display_type: 'text',
|
||||
attribute_key: key,
|
||||
attribute_model: this.attributeType,
|
||||
id: Math.random(),
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
|
|
@ -19,24 +19,18 @@ describe('attributeMixin', () => {
|
|||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
meta: {
|
||||
sender: {
|
||||
id: 1212,
|
||||
},
|
||||
},
|
||||
}),
|
||||
getCurrentAccountId: () => 1,
|
||||
};
|
||||
attributeType: () => 'conversation_attribute',
|
||||
};
|
||||
store = new Vuex.Store({ actions, getters });
|
||||
});
|
||||
|
||||
it('returns currently selected conversation custom attributes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.customAttributes).toEqual({
|
||||
product_id: 2021,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns currently selected conversation id', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
|
@ -56,6 +50,14 @@ describe('attributeMixin', () => {
|
|||
attributes() {
|
||||
return attributeFixtures;
|
||||
},
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
|
@ -86,4 +88,83 @@ describe('attributeMixin', () => {
|
|||
expect(wrapper.vm.attributeIcon('date')).toBe('ion-calendar');
|
||||
expect(wrapper.vm.attributeIcon()).toBe('ion-edit');
|
||||
});
|
||||
|
||||
it('returns currently selected contact', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
computed: {
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.contact).toEqual({
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns currently selected contact id', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.contactIdentifier).toEqual(1212);
|
||||
});
|
||||
|
||||
it('returns currently selected conversation custom attributes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
computed: {
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
product_id: 2021,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.customAttributes).toEqual({
|
||||
product_id: 2021,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns currently selected contact custom attributes', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'TestComponent',
|
||||
mixins: [attributeMixin],
|
||||
computed: {
|
||||
contact() {
|
||||
return {
|
||||
id: 7165,
|
||||
custom_attributes: {
|
||||
cloudCustomer: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
const wrapper = shallowMount(Component, { store, localVue });
|
||||
expect(wrapper.vm.customAttributes).toEqual({
|
||||
cloudCustomer: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,12 +13,21 @@
|
|||
@panel-close="onClose"
|
||||
/>
|
||||
<accordion-item
|
||||
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CUSTOM_ATTRIBUTES')"
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"
|
||||
compact
|
||||
@click="value => toggleSidebarUIState('is_ct_custom_attr_open', value)"
|
||||
>
|
||||
<contact-custom-attributes
|
||||
<custom-attributes
|
||||
:contact-id="contact.id"
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
class="even"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
<accordion-item
|
||||
|
@ -45,9 +54,10 @@
|
|||
<script>
|
||||
import AccordionItem from 'dashboard/components/Accordion/AccordionItem';
|
||||
import ContactConversations from 'dashboard/routes/dashboard/conversation/ContactConversations';
|
||||
import ContactCustomAttributes from 'dashboard/routes/dashboard/conversation/ContactCustomAttributes';
|
||||
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo';
|
||||
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
|
||||
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
|
||||
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
|
@ -55,9 +65,10 @@ export default {
|
|||
components: {
|
||||
AccordionItem,
|
||||
ContactConversations,
|
||||
ContactCustomAttributes,
|
||||
ContactInfo,
|
||||
ContactLabel,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
props: {
|
||||
|
|
|
@ -85,19 +85,26 @@
|
|||
>
|
||||
</conversation-info>
|
||||
</accordion-item>
|
||||
|
||||
<accordion-item
|
||||
v-if="hasContactAttributes"
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
|
||||
:is-open="isContactSidebarItemOpen('is_contact_attributes_open')"
|
||||
compact
|
||||
@click="
|
||||
value => toggleSidebarUIState('is_contact_attributes_open', value)
|
||||
"
|
||||
>
|
||||
<contact-custom-attributes
|
||||
:custom-attributes="contact.custom_attributes"
|
||||
<custom-attributes
|
||||
attribute-type="contact_attribute"
|
||||
attribute-class="conversation--attribute"
|
||||
class="even"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
<custom-attribute-selector
|
||||
attribute-type="contact_attribute"
|
||||
:contact-id="contact.id"
|
||||
/>
|
||||
</accordion-item>
|
||||
|
||||
<accordion-item
|
||||
v-if="contact.id"
|
||||
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.PREVIOUS_CONVERSATION')"
|
||||
|
@ -119,24 +126,27 @@ import agentMixin from '../../../mixins/agentMixin';
|
|||
|
||||
import AccordionItem from 'dashboard/components/Accordion/AccordionItem';
|
||||
import ContactConversations from './ContactConversations.vue';
|
||||
import ContactCustomAttributes from './ContactCustomAttributes';
|
||||
import ContactDetailsItem from './ContactDetailsItem.vue';
|
||||
import ContactInfo from './contact/ContactInfo';
|
||||
import ConversationInfo from './ConversationInfo';
|
||||
import ConversationLabels from './labels/LabelBox.vue';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
|
||||
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AccordionItem,
|
||||
ContactConversations,
|
||||
ContactCustomAttributes,
|
||||
ContactDetailsItem,
|
||||
ContactInfo,
|
||||
ConversationInfo,
|
||||
ConversationLabels,
|
||||
MultiselectDropdown,
|
||||
CustomAttributes,
|
||||
CustomAttributeSelector,
|
||||
},
|
||||
mixins: [alertMixin, agentMixin, uiSettingsMixin],
|
||||
props: {
|
||||
|
|
|
@ -27,7 +27,6 @@
|
|||
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
|
||||
</div>
|
||||
<woot-button
|
||||
variant="hollow"
|
||||
class="add"
|
||||
icon="ion-plus-round"
|
||||
size="tiny"
|
||||
|
@ -53,6 +52,7 @@ export default {
|
|||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
|
||||
data() {
|
||||
|
@ -76,7 +76,7 @@ export default {
|
|||
},
|
||||
|
||||
noResult() {
|
||||
return this.filteredAttributes.length === 0 && this.search !== '';
|
||||
return this.filteredAttributes.length === 0;
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -90,7 +90,7 @@ export default {
|
|||
},
|
||||
addNewAttribute() {
|
||||
this.$router.push(
|
||||
`/app/accounts/${this.accountId}/settings/attributes/list`
|
||||
`/app/accounts/${this.accountId}/settings/custom-attributes/list`
|
||||
);
|
||||
},
|
||||
async onAddAttribute(attribute) {
|
||||
|
@ -138,7 +138,6 @@ export default {
|
|||
width: 100%;
|
||||
.add {
|
||||
float: right;
|
||||
margin-top: var(--space-one);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@
|
|||
<custom-attribute-drop-down
|
||||
v-if="showAttributeDropDown"
|
||||
:attribute-type="attributeType"
|
||||
:contact-id="contactId"
|
||||
@add-attribute="addAttribute"
|
||||
/>
|
||||
</div>
|
||||
|
@ -47,6 +48,7 @@ export default {
|
|||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -55,15 +57,25 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
async addAttribute(attribute) {
|
||||
const { attribute_key } = attribute;
|
||||
try {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: {
|
||||
...this.customAttributes,
|
||||
[attribute_key]: null,
|
||||
},
|
||||
});
|
||||
const { attribute_key } = attribute;
|
||||
if (this.attributeType === 'conversation_attribute') {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: {
|
||||
...this.customAttributes,
|
||||
[attribute_key]: null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.$store.dispatch('contacts/update', {
|
||||
id: this.contactId,
|
||||
custom_attributes: {
|
||||
...this.customAttributes,
|
||||
[attribute_key]: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
bus.$emit(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, attribute_key);
|
||||
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.SUCCESS'));
|
||||
} catch (error) {
|
||||
|
|
|
@ -37,14 +37,23 @@ export default {
|
|||
type: String,
|
||||
default: '',
|
||||
},
|
||||
contactId: { type: Number, default: null },
|
||||
},
|
||||
methods: {
|
||||
async onUpdate(key, value) {
|
||||
const updatedAttributes = { ...this.customAttributes, [key]: value };
|
||||
try {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: { ...this.customAttributes, [key]: value },
|
||||
});
|
||||
if (this.attributeType === 'conversation_attribute') {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: updatedAttributes,
|
||||
});
|
||||
} else {
|
||||
this.$store.dispatch('contacts/update', {
|
||||
id: this.contactId,
|
||||
custom_attributes: updatedAttributes,
|
||||
});
|
||||
}
|
||||
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
@ -54,13 +63,20 @@ export default {
|
|||
}
|
||||
},
|
||||
async onDelete(key) {
|
||||
const { [key]: remove, ...updatedAttributes } = this.customAttributes;
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: updatedAttributes,
|
||||
});
|
||||
const { [key]: remove, ...updatedAttributes } = this.customAttributes;
|
||||
if (this.attributeType === 'conversation_attribute') {
|
||||
await this.$store.dispatch('updateCustomAttributes', {
|
||||
conversationId: this.conversationId,
|
||||
customAttributes: updatedAttributes,
|
||||
});
|
||||
} else {
|
||||
this.$store.dispatch('contacts/deleteCustomAttributes', {
|
||||
id: this.contactId,
|
||||
customAttributes: [key],
|
||||
});
|
||||
}
|
||||
|
||||
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
: ''
|
||||
"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.PLACEHOLDER')"
|
||||
@input="onDisplayNameChange"
|
||||
@blur="$v.displayName.$touch"
|
||||
/>
|
||||
<label :class="{ error: $v.description.$error }">
|
||||
|
@ -53,22 +54,22 @@
|
|||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="displayName" class="medium-12 columns">
|
||||
<label>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL') }}
|
||||
<i class="ion-help" />
|
||||
</label>
|
||||
<p class="key-value text-truncate">
|
||||
{{ attributeKey }}
|
||||
</p>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model="attributeKey"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: $v.attributeKey.$error }"
|
||||
:error="
|
||||
$v.attributeKey.$error
|
||||
? $t('ATTRIBUTES_MGMT.ADD.FORM.KEY.ERROR')
|
||||
: ''
|
||||
"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
|
||||
@blur="$v.attributeKey.$touch"
|
||||
/>
|
||||
<div class="modal-footer">
|
||||
<woot-submit-button
|
||||
:disabled="
|
||||
$v.displayName.$invalid ||
|
||||
$v.description.$invalid ||
|
||||
uiFlags.isCreating
|
||||
"
|
||||
:disabled="isButtonDisabled"
|
||||
:button-text="$t('ATTRIBUTES_MGMT.ADD.SUBMIT')"
|
||||
/>
|
||||
<button class="button clear" @click.prevent="onClose">
|
||||
|
@ -103,6 +104,7 @@ export default {
|
|||
description: '',
|
||||
attributeModel: 0,
|
||||
attributeType: 0,
|
||||
attributeKey: '',
|
||||
models: ATTRIBUTE_MODELS,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
|
@ -113,8 +115,12 @@ export default {
|
|||
...mapGetters({
|
||||
uiFlags: 'getUIFlags',
|
||||
}),
|
||||
attributeKey() {
|
||||
return convertToSlug(this.displayName);
|
||||
isButtonDisabled() {
|
||||
return (
|
||||
this.$v.displayName.$invalid ||
|
||||
this.$v.description.$invalid ||
|
||||
this.uiFlags.isCreating
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -132,9 +138,15 @@ export default {
|
|||
attributeType: {
|
||||
required,
|
||||
},
|
||||
attributeKey: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onDisplayNameChange() {
|
||||
this.attributeKey = convertToSlug(this.displayName);
|
||||
},
|
||||
async addAttributes() {
|
||||
try {
|
||||
await this.$store.dispatch('attributes/create', {
|
||||
|
|
|
@ -135,6 +135,10 @@ export default {
|
|||
key: 0,
|
||||
name: this.$t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
name: this.$t('ATTRIBUTES_MGMT.TABS.CONTACT'),
|
||||
},
|
||||
];
|
||||
},
|
||||
deleteConfirmText() {
|
||||
|
|
|
@ -41,15 +41,20 @@
|
|||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.ERROR') }}
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="displayName" class="medium-12 columns">
|
||||
<label>
|
||||
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL') }}
|
||||
<i class="ion-help" />
|
||||
</label>
|
||||
<p class="key-value text-truncate">
|
||||
{{ attributeKey }}
|
||||
</p>
|
||||
</div>
|
||||
<woot-input
|
||||
v-model.trim="attributeKey"
|
||||
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.LABEL')"
|
||||
type="text"
|
||||
:class="{ error: $v.attributeKey.$error }"
|
||||
:error="
|
||||
$v.attributeKey.$error
|
||||
? $t('ATTRIBUTES_MGMT.ADD.FORM.KEY.ERROR')
|
||||
: ''
|
||||
"
|
||||
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.KEY.PLACEHOLDER')"
|
||||
readonly
|
||||
@blur="$v.attributeKey.$touch"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<woot-button
|
||||
|
@ -69,7 +74,6 @@
|
|||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { required, minLength } from 'vuelidate/lib/validators';
|
||||
import { convertToSlug } from 'dashboard/helper/commons.js';
|
||||
import { ATTRIBUTE_TYPES } from './constants';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
export default {
|
||||
|
@ -92,6 +96,7 @@ export default {
|
|||
attributeType: 0,
|
||||
types: ATTRIBUTE_TYPES,
|
||||
show: true,
|
||||
attributeKey: '',
|
||||
};
|
||||
},
|
||||
validations: {
|
||||
|
@ -105,6 +110,9 @@ export default {
|
|||
required,
|
||||
minLength: minLength(1),
|
||||
},
|
||||
attributeKey: {
|
||||
required,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
|
@ -115,9 +123,6 @@ export default {
|
|||
this.selectedAttribute.attribute_display_name
|
||||
}`;
|
||||
},
|
||||
attributeKey() {
|
||||
return convertToSlug(this.displayName);
|
||||
},
|
||||
selectedAttributeType() {
|
||||
return this.types.find(
|
||||
item =>
|
||||
|
@ -137,6 +142,7 @@ export default {
|
|||
this.displayName = this.selectedAttribute.attribute_display_name;
|
||||
this.description = this.selectedAttribute.attribute_description;
|
||||
this.attributeType = this.selectedAttributeType;
|
||||
this.attributeKey = this.selectedAttribute.attribute_key;
|
||||
},
|
||||
async editAttributes() {
|
||||
this.$v.$touch();
|
||||
|
|
|
@ -5,7 +5,7 @@ import { frontendURL } from '../../../../helper/URLHelper';
|
|||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/attributes'),
|
||||
path: frontendURL('accounts/:accountId/settings/custom-attributes'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
|
||||
|
|
|
@ -3,6 +3,10 @@ export const ATTRIBUTE_MODELS = [
|
|||
id: 0,
|
||||
option: 'Conversation',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
option: 'Contact',
|
||||
},
|
||||
];
|
||||
|
||||
export const ATTRIBUTE_TYPES = [
|
||||
|
|
|
@ -24,10 +24,10 @@ export const getters = {
|
|||
};
|
||||
|
||||
export const actions = {
|
||||
get: async function getAttributesByModel({ commit }, modelId) {
|
||||
get: async function getAttributesByModel({ commit }) {
|
||||
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: true });
|
||||
try {
|
||||
const response = await AttributeAPI.getAttributesByModel(modelId);
|
||||
const response = await AttributeAPI.getAttributesByModel();
|
||||
commit(types.SET_CUSTOM_ATTRIBUTE, response.data);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
|
|
|
@ -110,6 +110,18 @@ export const actions = {
|
|||
}
|
||||
},
|
||||
|
||||
deleteCustomAttributes: async ({ commit }, { id, customAttributes }) => {
|
||||
try {
|
||||
const response = await ContactAPI.destroyCustomAttributes(
|
||||
id,
|
||||
customAttributes
|
||||
);
|
||||
commit(types.EDIT_CONTACT, response.data.payload);
|
||||
} catch (error) {
|
||||
throw new Error(error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchContactableInbox: async ({ commit }, id) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
|
||||
try {
|
||||
|
|
|
@ -206,4 +206,24 @@ describe('#actions', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteCustomAttributes', () => {
|
||||
it('sends correct mutations if API is success', async () => {
|
||||
axios.post.mockResolvedValue({ data: { payload: contactList[0] } });
|
||||
await actions.deleteCustomAttributes(
|
||||
{ commit },
|
||||
{ id: 1, customAttributes: ['cloud-customer'] }
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([[types.EDIT_CONTACT, contactList[0]]]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
|
||||
await expect(
|
||||
actions.deleteCustomAttributes(
|
||||
{ commit },
|
||||
{ id: 1, customAttributes: ['cloud-customer'] }
|
||||
)
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
# index_custom_attribute_definitions_on_account_id (account_id)
|
||||
#
|
||||
class CustomAttributeDefinition < ApplicationRecord
|
||||
scope :with_attribute_model, ->(attribute_model) { attribute_model.presence && where(attribute_model: attribute_model) }
|
||||
validates :attribute_display_name, presence: true
|
||||
|
||||
validates :attribute_key,
|
||||
|
|
|
@ -27,6 +27,10 @@ class ContactPolicy < ApplicationPolicy
|
|||
true
|
||||
end
|
||||
|
||||
def destroy_custom_attributes?
|
||||
true
|
||||
end
|
||||
|
||||
def show?
|
||||
true
|
||||
end
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
json.payload do
|
||||
json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact, with_contact_inboxes: true
|
||||
end
|
|
@ -88,6 +88,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
member do
|
||||
get :contactable_inboxes
|
||||
post :destroy_custom_attributes
|
||||
end
|
||||
scope module: :contacts do
|
||||
resources :conversations, only: [:index]
|
||||
|
@ -183,7 +184,11 @@ Rails.application.routes.draw do
|
|||
post :transcript
|
||||
end
|
||||
end
|
||||
resource :contact, only: [:show, :update]
|
||||
resource :contact, only: [:show, :update] do
|
||||
collection do
|
||||
delete :destroy_custom_attributes
|
||||
end
|
||||
end
|
||||
resources :inbox_members, only: [:index]
|
||||
resources :labels, only: [:create, :destroy]
|
||||
end
|
||||
|
|
|
@ -501,4 +501,33 @@ RSpec.describe 'Contacts API', type: :request do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/contacts/:id/destroy_custom_attributes' do
|
||||
let(:custom_attributes) { { test: 'test', test1: 'test1' } }
|
||||
let!(:contact) { create(:contact, account: account, custom_attributes: custom_attributes) }
|
||||
let(:valid_params) { { custom_attributes: ['test'] } }
|
||||
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/destroy_custom_attributes",
|
||||
params: valid_params
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'delete the custom attribute' do
|
||||
post "/api/v1/accounts/#{account.id}/contacts/#{contact.id}/destroy_custom_attributes",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(contact.reload.custom_attributes).to eq({ 'test1' => 'test1' })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -25,7 +25,7 @@ RSpec.describe 'Custom Attribute Definitions API', type: :request do
|
|||
expect(response).to have_http_status(:success)
|
||||
response_body = JSON.parse(response.body)
|
||||
|
||||
expect(response_body.count).to eq(1)
|
||||
expect(response_body.count).to eq(2)
|
||||
expect(response_body.first['attribute_key']).to eq(custom_attribute_definition.attribute_key)
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue