feat: Render contact custom attributes in contact/conversation sidebar (#3310)

This commit is contained in:
Muhsin Keloth 2021-11-11 15:23:33 +05:30 committed by GitHub
parent e12edb51a2
commit 76370267f3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 416 additions and 124 deletions

View file

@ -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:

View file

@ -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)

View file

@ -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

View file

@ -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);
}
}

View file

@ -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();

View file

@ -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);

View 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);
}

View file

@ -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: {

View file

@ -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"
}
}
}

View file

@ -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"
},

View file

@ -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",

View file

@ -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: {

View file

@ -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: {

View file

@ -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,
});
});
});

View file

@ -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: {

View file

@ -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: {

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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 =

View file

@ -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', {

View file

@ -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() {

View file

@ -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();

View file

@ -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',

View file

@ -3,6 +3,10 @@ export const ATTRIBUTE_MODELS = [
id: 0,
option: 'Conversation',
},
{
id: 1,
option: 'Contact',
},
];
export const ATTRIBUTE_TYPES = [

View file

@ -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

View file

@ -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 {

View file

@ -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);
});
});
});

View file

@ -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,

View file

@ -27,6 +27,10 @@ class ContactPolicy < ApplicationPolicy
true
end
def destroy_custom_attributes?
true
end
def show?
true
end

View file

@ -0,0 +1,3 @@
json.payload do
json.partial! 'api/v1/models/contact.json.jbuilder', resource: @contact, with_contact_inboxes: true
end

View file

@ -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

View file

@ -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

View file

@ -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