feat: Render conversation custom attributes (#3065)

This commit is contained in:
Muhsin Keloth 2021-10-30 07:09:46 +05:30 committed by GitHub
parent 69f55a25b6
commit ab77e03c92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1233 additions and 182 deletions

View file

@ -17,7 +17,11 @@ checks:
method-count:
enabled: true
config:
threshold: 25
threshold: 30
file-lines:
enabled: true
config:
threshold: 300
exclude_patterns:
- "spec/"
- "**/specs/"

View file

@ -80,6 +80,12 @@ class ConversationApi extends ApiClient {
sendEmailTranscript({ conversationId, email }) {
return axios.post(`${this.url}/${conversationId}/transcript`, { email });
}
updateCustomAttributes({ conversationId, customAttributes }) {
return axios.post(`${this.url}/${conversationId}/custom_attributes`, {
custom_attributes: customAttributes,
});
}
}
export default new ConversationApi();

View file

@ -160,5 +160,18 @@ describe('#ConversationAPI', () => {
}
);
});
it('#updateCustomAttributes', () => {
conversationAPI.updateCustomAttributes({
conversationId: 45,
customAttributes: { order_d: '1001' },
});
expect(context.axiosMock.post).toHaveBeenCalledWith(
'/api/v1/conversations/45/custom_attributes',
{
custom_attributes: { order_d: '1001' },
}
);
});
});
});

View file

@ -1,8 +1,7 @@
.error {
#{$all-text-inputs},
select,
.multiselect>.multiselect__tags {
.multiselect > .multiselect__tags {
@include thin-border(var(--r-400));
}
@ -40,4 +39,8 @@ input {
font-size: var(--font-size-small);
height: var(--space-large);
}
.error {
border-color: var(--r-400);
}
}

View file

@ -15,7 +15,11 @@
</div>
</div>
</button>
<div v-if="isOpen" class="cw-accordion--content">
<div
v-if="isOpen"
class="cw-accordion--content"
:class="{ compact: compact }"
>
<slot />
</div>
</div>
@ -33,6 +37,10 @@ export default {
type: String,
required: true,
},
compact: {
type: Boolean,
default: false,
},
icon: {
type: String,
default: '',
@ -106,5 +114,9 @@ export default {
.cw-accordion--content {
padding: var(--space-normal);
&.compact {
padding: 0;
}
}
</style>

View file

@ -0,0 +1,220 @@
<template>
<div class="contact-attribute">
<div class="title-wrap">
<h4 class="text-block-title title error">
<span class="attribute-name" :class="{ error: $v.editedValue.$error }">
{{ label }}
</span>
</h4>
</div>
<div v-show="isEditing">
<div class="input-group small">
<input
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="input-group-field"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@keyup.enter="onUpdate"
/>
<div class="input-group-button">
<woot-button size="small" icon="ion-checkmark" @click="onUpdate" />
</div>
</div>
<span v-if="shouldShowErrorMessage" class="error-message">
{{ errorMessage }}
</span>
</div>
<div
v-show="!isEditing"
class="value--view"
:class="{ 'is-editable': showActions }"
>
<a
v-if="isAttributeTypeLink"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="value"
>
{{ value || '---' }}
</a>
<p v-else class="value">
{{ value || '---' }}
</p>
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-clipboard"
class-names="edit-button"
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-compose"
class-names="edit-button"
@click="onEdit"
/>
<woot-button
v-if="showActions"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="small"
color-scheme="secondary"
icon="ion-trash-a"
class-names="edit-button"
@click="onDelete"
/>
</div>
</div>
</template>
<script>
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
props: {
label: { type: String, required: true },
value: { type: [String, Number], default: '' },
showActions: { type: Boolean, default: false },
attributeType: { type: String, default: 'text' },
attributeKey: { type: String, required: true },
},
data() {
return {
isEditing: false,
editedValue: this.value,
};
},
validations() {
if (this.isAttributeTypeLink) {
return {
editedValue: { required, url },
};
}
return {
editedValue: { required },
};
},
computed: {
isAttributeTypeLink() {
return this.attributeType === 'link';
},
inputType() {
return this.isAttributeTypeLink ? 'url' : this.attributeType;
},
shouldShowErrorMessage() {
return this.$v.editedValue.$error;
},
errorMessage() {
if (this.$v.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
},
mounted() {
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {
if (this.attributeKey === focusAttributeKey) {
this.onEdit();
}
});
},
methods: {
focusInput() {
if (this.$refs.inputfield) {
this.$refs.inputfield.focus();
}
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
this.focusInput();
});
},
onUpdate() {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.isEditing = false;
this.$emit('update', this.attributeKey, this.editedValue);
},
onDelete() {
this.isEditing = false;
this.$emit('delete', this.attributeKey);
},
onCopy() {
this.$emit('copy', this.value);
},
},
};
</script>
<style lang="scss" scoped>
.contact-attribute {
padding: var(--space-slab) var(--space-normal);
}
.title-wrap {
display: flex;
align-items: center;
margin-bottom: var(--space-mini);
}
.title {
display: flex;
align-items: center;
margin: 0;
}
.attribute-name {
&.error {
color: var(--r-400);
}
}
.title--icon {
width: var(--space-two);
}
.edit-button {
display: none;
}
.value--view {
display: flex;
&.is-editable:hover {
.value {
background: var(--color-background);
}
.edit-button {
display: block;
}
}
}
.value {
display: inline-block;
min-width: var(--space-mega);
border-radius: var(--border-radius-small);
word-break: break-all;
padding: var(--space-micro) var(--space-smaller);
}
.error-message {
color: var(--r-400);
display: block;
font-size: 1.4rem;
font-size: var(--font-size-small);
font-weight: 400;
margin-bottom: 1rem;
margin-top: -1.6rem;
width: 100%;
}
</style>

View file

@ -20,8 +20,8 @@
"ERROR": "Description is required"
},
"MODEL": {
"LABEL": "Model",
"PLACEHOLDER": "Please select a model",
"LABEL": "Applies to",
"PLACEHOLDER": "Please select one",
"ERROR": "Model is required"
},
"TYPE": {

View file

@ -237,8 +237,15 @@
}
},
"CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Add attributes",
"BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"ACTIONS": {
"COPY": "Copy attribute",
"DELETE": "Delete attribute",
"EDIT": "Edit attribute"
},
"ADD": {
"TITLE": "Create custom attribute",
"DESC": "Add custom information to this contact."
@ -254,7 +261,29 @@
"VALUE": {
"LABEL": "Attribute value",
"PLACEHOLDER": "Eg: 11901 "
},
"ADD": {
"TITLE": "Add",
"SUCCESS": "Attribute added successfully",
"ERROR": "Unable to add attribute. Please try again later"
},
"UPDATE": {
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
},
"DELETE": {
"SUCCESS": "Attribute deleted successfully",
"ERROR": "Unable to delete attribute. Please try again later"
},
"ATTRIBUTE_SELECT": {
"TITLE": "Add attributes",
"PLACEHOLDER": "Search attributes",
"NO_RESULT": "No attributes found"
}
},
"VALIDATIONS": {
"REQUIRED": "Valid value is required",
"INVALID_URL": "Invalid URL"
}
},
"MERGE_CONTACTS": {

View file

@ -156,6 +156,27 @@
"PREVIOUS_CONVERSATION": "Previous Conversations"
}
},
"CONVERSATION_CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Create attribute",
"UPDATE": {
"SUCCESS": "Attribute updated successfully",
"ERROR": "Unable to update attribute. Please try again later"
},
"ADD": {
"TITLE": "Add",
"SUCCESS": "Attribute added successfully",
"ERROR": "Unable to add attribute. Please try again later"
},
"DELETE": {
"SUCCESS": "Attribute deleted successfully",
"ERROR": "Unable to delete attribute. Please try again later"
},
"ATTRIBUTE_SELECT": {
"TITLE": "Add attributes",
"PLACEHOLDER": "Search attributes",
"NO_RESULT": "No attributes found"
}
},
"EMAIL_HEADER": {
"TO": "To",
"BCC": "Bcc",

View file

@ -0,0 +1,56 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
}),
attributes() {
return this.$store.getters['attributes/getAttributesByModel'](
'conversation_attribute'
);
},
customAttributes() {
return this.currentChat.custom_attributes || {};
},
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 {
...item,
value: this.customAttributes[key],
icon: this.attributeIcon(item.attribute_display_type),
};
});
},
},
methods: {
attributeIcon(attributeType) {
switch (attributeType) {
case 'date':
return 'ion-calendar';
case 'link':
return 'ion-link';
case 'currency':
return 'ion-social-usd';
case 'number':
return 'ion-calculator';
case 'percent':
return 'ion-calculator';
default:
return 'ion-edit';
}
},
},
};

View file

@ -0,0 +1,26 @@
export default [
{
attribute_description: 'Product name',
attribute_display_name: 'Product name',
attribute_display_type: 'text',
attribute_key: 'product_name',
attribute_model: 'conversation_attribute',
created_at: '2021-09-03T10:45:09.587Z',
default_value: null,
id: 6,
updated_at: '2021-09-22T10:40:42.511Z',
},
{
attribute_description: 'Product identifier',
attribute_display_name: 'Product id',
attribute_display_type: 'number',
attribute_key: 'product_id',
attribute_model: 'conversation_attribute',
created_at: '2021-09-16T13:06:47.329Z',
default_value: null,
icon: 'ion-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
];

View file

@ -0,0 +1,89 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import attributeMixin from '../attributeMixin';
import Vuex from 'vuex';
import attributeFixtures from './attributeFixtures';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('attributeMixin', () => {
let getters;
let actions;
let store;
beforeEach(() => {
actions = { updateUISettings: jest.fn(), toggleSidebarUIState: jest.fn() };
getters = {
getSelectedChat: () => ({
id: 7165,
custom_attributes: {
product_id: 2021,
},
}),
getCurrentAccountId: () => 1,
};
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() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.conversationId).toEqual(7165);
});
it('returns filtered attributes from conversation custom attributes', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
computed: {
attributes() {
return attributeFixtures;
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.filteredAttributes).toEqual([
{
attribute_description: 'Product identifier',
attribute_display_name: 'Product id',
attribute_display_type: 'number',
attribute_key: 'product_id',
attribute_model: 'conversation_attribute',
created_at: '2021-09-16T13:06:47.329Z',
default_value: null,
icon: 'ion-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
]);
});
it('return icon if attribute type passed correctly', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [attributeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.attributeIcon('date')).toBe('ion-calendar');
expect(wrapper.vm.attributeIcon()).toBe('ion-edit');
});
});

View file

@ -74,8 +74,4 @@ export default {
margin-bottom: var(--space-normal);
color: var(--b-500);
}
.conv-details--item {
padding-bottom: 0;
}
</style>

View file

@ -61,10 +61,6 @@ export default {
margin-bottom: var(--space-normal);
}
.conv-details--item {
padding-bottom: 0;
}
.custom-attribute--row__attribute {
font-weight: 500;
}

View file

@ -1,9 +1,8 @@
<template>
<div class="conv-details--item">
<div class="conv-details--item" :class="{ compact: compact }">
<h4 class="conv-details--item__label text-block-title">
<span class="title--icon">
<emoji-or-icon :icon="icon" :emoji="emoji" />
<span>{{ title }}</span>
<span class="item__title">
{{ title }}
</span>
<slot name="button"></slot>
</h4>
@ -16,17 +15,13 @@
</template>
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
export default {
components: {
EmojiOrIcon,
},
props: {
title: { type: String, required: true },
icon: { type: String, default: '' },
emoji: { type: String, default: '' },
value: { type: [String, Number], default: '' },
compact: { type: Boolean, default: false },
},
};
</script>
@ -34,6 +29,12 @@ export default {
<style lang="scss" scoped>
.conv-details--item {
overflow: auto;
padding: var(--space-slab) var(--space-normal);
&.compact {
padding: 0;
}
.conv-details--item__label {
align-items: center;
display: flex;
@ -46,19 +47,6 @@ export default {
.conv-details--item__value {
word-break: break-all;
margin-left: var(--space-medium);
margin-bottom: var(--space-normal);
}
.title--icon .icon--emoji,
.title--icon .icon--font {
display: inline-block;
width: var(--space-medium);
}
.title--icon {
display: flex;
align-items: center;
}
}
</style>

View file

@ -13,6 +13,7 @@
<div>
<div class="multiselect-wrap--small">
<contact-details-item
compact
:title="$t('CONVERSATION_SIDEBAR.ASSIGNEE_LABEL')"
>
<template v-slot:button>
@ -45,6 +46,7 @@
</div>
<div class="multiselect-wrap--small">
<contact-details-item
compact
:title="$t('CONVERSATION_SIDEBAR.TEAM_LABEL')"
/>
<multiselect-dropdown
@ -64,68 +66,26 @@
/>
</div>
<contact-details-item
compact
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_LABELS')"
/>
<conversation-labels :conversation-id="conversationId" />
</div>
</accordion-item>
</div>
<accordion-item
v-if="browser.browser_name"
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONVERSATION_INFO')"
:is-open="isContactSidebarItemOpen('is_conv_details_open')"
compact
@click="value => toggleSidebarUIState('is_conv_details_open', value)"
>
<div class="conversation--details">
<contact-details-item
v-if="location"
:title="$t('CONTACT_FORM.FORM.LOCATION.LABEL')"
:value="location"
icon="ion-map"
emoji="📍"
/>
<contact-details-item
v-if="ipAddress"
:title="$t('CONTACT_PANEL.IP_ADDRESS')"
:value="ipAddress"
icon="ion-android-locate"
emoji="🧭"
/>
<contact-details-item
v-if="browser.browser_name"
:title="$t('CONTACT_PANEL.BROWSER')"
:value="browserName"
icon="ion-ios-world-outline"
emoji="🌐"
/>
<contact-details-item
v-if="browser.platform_name"
:title="$t('CONTACT_PANEL.OS')"
:value="platformName"
icon="ion-laptop"
emoji="💻"
/>
<contact-details-item
v-if="referer"
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
:value="referer"
icon="ion-link"
emoji="🔗"
<conversation-info
:conversation-attributes="conversationAdditionalAttributes"
:contact-attributes="contactAdditionalAttributes"
>
<a :href="referer" rel="noopener noreferrer nofollow" target="_blank">
{{ referer }}
</a>
</contact-details-item>
<contact-details-item
v-if="initiatedAt"
:title="$t('CONTACT_PANEL.INITIATED_AT')"
:value="initiatedAt.timestamp"
icon="ion-clock"
emoji="🕰"
/>
</div>
</conversation-info>
</accordion-item>
<accordion-item
v-if="hasContactAttributes"
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
@ -162,21 +122,21 @@ 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 uiSettingsMixin from 'dashboard/mixins/uiSettings';
import flag from 'country-code-emoji';
export default {
components: {
ContactCustomAttributes,
AccordionItem,
ContactConversations,
ContactCustomAttributes,
ContactDetailsItem,
ContactInfo,
ConversationInfo,
ConversationLabels,
MultiselectDropdown,
AccordionItem,
},
mixins: [alertMixin, agentMixin, uiSettingsMixin],
props: {
@ -200,68 +160,30 @@ export default {
currentUser: 'getCurrentUser',
uiFlags: 'inboxAssignableAgents/getUIFlags',
}),
conversationAdditionalAttributes() {
return this.currentConversationMetaData.additional_attributes || {};
},
channelType() {
return this.currentChat.meta?.channel;
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
contactAdditionalAttributes() {
return this.contact.additional_attributes || {};
},
contactId() {
return this.currentChat.meta?.sender?.id;
},
currentConversationMetaData() {
return this.$store.getters[
'conversationMetadata/getConversationMetadata'
](this.conversationId);
},
additionalAttributes() {
return this.currentConversationMetaData.additional_attributes || {};
},
hasContactAttributes() {
const { custom_attributes: customAttributes } = this.contact;
return customAttributes && Object.keys(customAttributes).length;
},
browser() {
return this.additionalAttributes.browser || {};
},
referer() {
return this.additionalAttributes.referer;
},
initiatedAt() {
return this.additionalAttributes.initiated_at;
},
browserName() {
return `${this.browser.browser_name || ''} ${this.browser
.browser_version || ''}`;
},
contactAdditionalAttributes() {
return this.contact.additional_attributes || {};
},
ipAddress() {
const { created_at_ip: createdAtIp } = this.contactAdditionalAttributes;
return createdAtIp;
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.contactAdditionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) {
return '';
}
const countryFlag = countryCode ? flag(countryCode) : '🌎';
return `${cityAndCountry} ${countryFlag}`;
},
platformName() {
const {
platform_name: platformName,
platform_version: platformVersion,
} = this.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
channelType() {
return this.currentChat.meta?.channel;
},
contactId() {
return this.currentChat.meta?.sender?.id;
},
contact() {
return this.$store.getters['contacts/getContact'](this.contactId);
},
teamsList() {
if (this.assignedTeam) {
return [
@ -330,6 +252,7 @@ export default {
},
mounted() {
this.getContactDetails();
this.$store.dispatch('attributes/get', 0);
},
methods: {
onPanelToggle() {
@ -340,6 +263,11 @@ export default {
this.$store.dispatch('contacts/show', { id: this.contactId });
}
},
getAttributesByModel() {
if (this.contactId) {
this.$store.dispatch('contacts/show', { id: this.contactId });
}
},
openTranscriptModal() {
this.showTranscriptModal = true;
},

View file

@ -0,0 +1,144 @@
<template>
<div class="conversation--details">
<contact-details-item
v-if="initiatedAt"
:title="$t('CONTACT_PANEL.INITIATED_AT')"
:value="initiatedAt.timestamp"
class="conversation--attribute"
/>
<contact-details-item
v-if="referer"
:title="$t('CONTACT_PANEL.INITIATED_FROM')"
:value="referer"
class="conversation--attribute"
>
<a :href="referer" rel="noopener noreferrer nofollow" target="_blank">
{{ referer }}
</a>
</contact-details-item>
<contact-details-item
v-if="browserName"
:title="$t('CONTACT_PANEL.BROWSER')"
:value="browserName"
class="conversation--attribute"
/>
<contact-details-item
v-if="platformName"
:title="$t('CONTACT_PANEL.OS')"
:value="platformName"
class="conversation--attribute"
/>
<contact-details-item
v-if="ipAddress"
:title="$t('CONTACT_PANEL.IP_ADDRESS')"
:value="ipAddress"
class="conversation--attribute"
/>
<custom-attributes
attribute-type="conversation_attribute"
attribute-class="conversation--attribute"
:class="customAttributeRowClass"
/>
<custom-attribute-selector attribute-type="conversation_attribute" />
</div>
</template>
<script>
import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
export default {
components: {
ContactDetailsItem,
CustomAttributes,
CustomAttributeSelector,
},
props: {
conversationAttributes: {
type: Object,
default: () => ({}),
},
contactAttributes: {
type: Object,
default: () => ({}),
},
},
STATIC_ATTRIBUTES: [
{
name: 'initiated_at',
label: 'CONTACT_PANEL.INITIATED_AT',
},
{
name: 'referer',
label: 'CONTACT_PANEL.BROWSER',
},
{
name: 'browserName',
label: 'CONTACT_PANEL.BROWSER',
},
{
name: 'platformName',
label: 'CONTACT_PANEL.OS',
},
{
name: 'ipAddress',
label: 'CONTACT_PANEL.IP_ADDRESS',
},
],
computed: {
referer() {
return this.conversationAttributes.referer;
},
initiatedAt() {
return this.conversationAttributes.initiated_at;
},
browserName() {
if (!this.conversationAttributes.browser) {
return '';
}
const {
browser_name: browserName = '',
browser_version: browserVersion = '',
} = this.conversationAttributes.browser;
return `${browserName} ${browserVersion}`;
},
platformName() {
if (!this.conversationAttributes.browser) {
return '';
}
const {
platform_name: platformName,
platform_version: platformVersion,
} = this.conversationAttributes.browser;
return `${platformName || ''} ${platformVersion || ''}`;
},
ipAddress() {
const { created_at_ip: createdAtIp } = this.contactAttributes;
return createdAtIp;
},
customAttributeRowClass() {
const attributes = [
'initiatedAt',
'referer',
'browserName',
'platformName',
'ipAddress',
];
const availableAttributes = attributes.filter(
attribute => !!this[attribute]
);
return availableAttributes.length % 2 === 0 ? 'even' : 'odd';
},
},
};
</script>
<style scoped lang="scss">
.conversation--attribute {
border-bottom: 1px solid var(--color-border-light);
&:nth-child(2n) {
background: var(--b-50);
}
}
</style>

View file

@ -41,19 +41,19 @@
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
/>
<contact-info-row
v-if="additionalAttributes.location"
:value="additionalAttributes.location"
icon="ion-map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
<contact-info-row
:value="additionalAttributes.company_name"
icon="ion-briefcase"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
/>
<contact-info-row
v-if="location || additionalAttributes.location"
:value="location || additionalAttributes.location"
icon="ion-map"
emoji="🌍"
:title="$t('CONTACT_PANEL.LOCATION')"
/>
</div>
</div>
<div class="contact-actions">
@ -145,6 +145,7 @@ import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal';
import alertMixin from 'shared/mixins/alertMixin';
import adminMixin from '../../../../mixins/isAdmin';
import { mapGetters } from 'vuex';
import flag from 'country-code-emoji';
export default {
components: {
@ -190,6 +191,20 @@ export default {
additionalAttributes() {
return this.contact.additional_attributes || {};
},
location() {
const {
country = '',
city = '',
country_code: countryCode,
} = this.additionalAttributes;
const cityAndCountry = [city, country].filter(item => !!item).join(', ');
if (!cityAndCountry) {
return '';
}
const countryFlag = countryCode ? flag(countryCode) : '🌎';
return `${cityAndCountry} ${countryFlag}`;
},
socialProfiles() {
const {
social_profiles: socialProfiles,
@ -294,7 +309,7 @@ export default {
}
.contact--metadata {
margin-bottom: var(--space-small);
margin-bottom: var(--space-slab);
}
.contact-actions {

View file

@ -0,0 +1,155 @@
<template>
<div class="dropdown-search-wrap">
<h4 class="text-block-title">
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.TITLE') }}
</h4>
<div class="search-wrap">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.PLACEHOLDER')"
/>
</div>
<div class="list-wrap">
<div class="list">
<woot-dropdown-menu>
<custom-attribute-drop-down-item
v-for="attribute in filteredAttributes"
:key="attribute.attribute_display_name"
:title="attribute.attribute_display_name"
@click="onAddAttribute(attribute)"
/>
</woot-dropdown-menu>
<div v-if="noResult" class="no-result">
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
</div>
<woot-button
variant="hollow"
class="add"
icon="ion-plus-round"
size="tiny"
@click="addNewAttribute"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ADD.TITLE') }}
</woot-button>
</div>
</div>
</div>
</template>
<script>
import CustomAttributeDropDownItem from './CustomAttributeDropDownItem.vue';
import attributeMixin from 'dashboard/mixins/attributeMixin';
export default {
components: {
CustomAttributeDropDownItem,
},
mixins: [attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
},
data() {
return {
search: '',
};
},
computed: {
filteredAttributes() {
return this.attributes
.filter(
item =>
!Object.keys(this.customAttributes).includes(item.attribute_key)
)
.filter(attribute => {
return attribute.attribute_display_name
.toLowerCase()
.includes(this.search.toLowerCase());
});
},
noResult() {
return this.filteredAttributes.length === 0 && this.search !== '';
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
addNewAttribute() {
this.$router.push(
`/app/accounts/${this.accountId}/settings/attributes/list`
);
},
async onAddAttribute(attribute) {
this.$emit('add-attribute', attribute);
},
},
};
</script>
<style lang="scss" scoped>
.dropdown-search-wrap {
display: flex;
flex-direction: column;
width: 100%;
max-height: 20rem;
.search-wrap {
margin-bottom: var(--space-small);
flex: 0 0 auto;
max-height: var(--space-large);
.search-input {
margin: 0;
width: 100%;
border: 1px solid transparent;
height: var(--space-large);
font-size: var(--font-size-small);
padding: var(--space-small);
background-color: var(--color-background);
}
input:focus {
border: 1px solid var(--w-500);
}
}
.list-wrap {
display: flex;
justify-content: flex-start;
align-items: flex-start;
flex: 1 1 auto;
overflow: auto;
.list {
width: 100%;
.add {
float: right;
margin-top: var(--space-one);
}
}
.no-result {
display: flex;
justify-content: center;
color: var(--s-700);
padding: var(--space-smaller) var(--space-one);
font-weight: var(--font-weight-medium);
font-size: var(--font-size-small);
}
}
}
</style>

View file

@ -0,0 +1,79 @@
<template>
<woot-dropdown-item>
<woot-button variant="clear" @click="onClick">
<span class="label-text" :title="title">{{ title }}</span>
</woot-button>
</woot-dropdown-item>
</template>
<script>
export default {
name: 'AttributeDropDownItem',
props: {
title: {
type: String,
default: '',
},
},
methods: {
onClick() {
this.$emit('click', this.title);
},
},
};
</script>
<style lang="scss" scoped>
.item-wrap {
display: flex;
::v-deep .button__content {
width: 100%;
}
.button-wrap {
display: flex;
justify-content: space-between;
width: 100%;
&.active {
display: flex;
font-weight: var(--font-weight-bold);
color: var(--w-700);
}
.name-label-wrap {
display: flex;
min-width: 0;
width: 100%;
.label-color--display {
margin-right: var(--space-small);
}
.label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
padding-right: var(--space-small);
padding-left: var(--space-small);
}
.icon {
font-size: var(--font-size-small);
}
}
}
.label-color--display {
border-radius: var(--border-radius-normal);
height: var(--space-slab);
margin-right: var(--space-smaller);
margin-top: var(--space-micro);
min-width: var(--space-slab);
width: var(--space-slab);
}
}
</style>

View file

@ -0,0 +1,120 @@
<template>
<div class="custom-attribute--selector">
<div
v-on-clickaway="closeDropdown"
class="label-wrap"
@keyup.esc="closeDropdown"
>
<woot-button
size="small"
variant="link"
icon="ion-plus"
@click="toggleAttributeDropDown"
>
{{ $t('CUSTOM_ATTRIBUTES.ADD_BUTTON_TEXT') }}
</woot-button>
<div class="dropdown-wrap">
<div
:class="{ 'dropdown-pane--open': showAttributeDropDown }"
class="dropdown-pane"
>
<custom-attribute-drop-down
v-if="showAttributeDropDown"
:attribute-type="attributeType"
@add-attribute="addAttribute"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import CustomAttributeDropDown from './CustomAttributeDropDown.vue';
import alertMixin from 'shared/mixins/alertMixin';
import attributeMixin from 'dashboard/mixins/attributeMixin';
import { mixin as clickaway } from 'vue-clickaway';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
components: {
CustomAttributeDropDown,
},
mixins: [clickaway, alertMixin, attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
},
data() {
return {
showAttributeDropDown: false,
};
},
methods: {
async addAttribute(attribute) {
const { attribute_key } = attribute;
try {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: {
...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) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.ADD.ERROR');
this.showAlert(errorMessage);
} finally {
this.closeDropdown();
}
},
toggleAttributeDropDown() {
this.showAttributeDropDown = !this.showAttributeDropDown;
},
closeDropdown() {
this.showAttributeDropDown = false;
},
},
};
</script>
<style lang="scss" scoped>
.custom-attribute--selector {
width: 100%;
padding: var(--space-slab) var(--space-normal);
.label-wrap {
line-height: var(--space-medium);
position: relative;
.dropdown-wrap {
display: flex;
left: -1px;
margin-right: var(--space-medium);
position: absolute;
top: var(--space-medium);
width: 100%;
.dropdown-pane {
width: 100%;
box-sizing: border-box;
}
}
}
}
.error {
color: var(--r-500);
font-size: var(--font-size-mini);
font-weight: var(--font-weight-medium);
}
</style>

View file

@ -0,0 +1,101 @@
<template>
<div class="custom-attributes--panel">
<custom-attribute
v-for="attribute in filteredAttributes"
:key="attribute.id"
:attribute-key="attribute.attribute_key"
:attribute-type="attribute.attribute_display_type"
:label="attribute.attribute_display_name"
:icon="attribute.icon"
emoji=""
:value="attribute.value"
:show-actions="true"
:class="attributeClass"
@update="onUpdate"
@delete="onDelete"
@copy="onCopy"
/>
</div>
</template>
<script>
import copy from 'copy-text-to-clipboard';
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import alertMixin from 'shared/mixins/alertMixin';
import attributeMixin from 'dashboard/mixins/attributeMixin';
export default {
components: {
CustomAttribute,
},
mixins: [alertMixin, attributeMixin],
props: {
attributeType: {
type: String,
default: 'conversation_attribute',
},
attributeClass: {
type: String,
default: '',
},
},
methods: {
async onUpdate(key, value) {
try {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: { ...this.customAttributes, [key]: value },
});
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.UPDATE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.UPDATE.ERROR');
this.showAlert(errorMessage);
}
},
async onDelete(key) {
const { [key]: remove, ...updatedAttributes } = this.customAttributes;
try {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: updatedAttributes,
});
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.FORM.DELETE.SUCCESS'));
} catch (error) {
const errorMessage =
error?.response?.message ||
this.$t('CUSTOM_ATTRIBUTES.FORM.DELETE.ERROR');
this.showAlert(errorMessage);
}
},
onCopy(attributeValue) {
copy(attributeValue);
this.showAlert(this.$t('CUSTOM_ATTRIBUTES.COPY_SUCCESSFUL'));
},
},
};
</script>
<style scoped lang="scss">
.custom-attributes--panel {
.conversation--attribute {
border-bottom: 1px solid var(--color-border-light);
}
&.odd {
.conversation--attribute {
&:nth-child(2n + 1) {
background: var(--b-50);
}
}
}
&.even {
.conversation--attribute {
&:nth-child(2n) {
background: var(--b-50);
}
}
}
}
</style>

View file

@ -3,8 +3,19 @@
<div class="column content-box">
<woot-modal-header :header-title="$t('ATTRIBUTES_MGMT.ADD.TITLE')" />
<form class="row" @submit.prevent="addAttributes()">
<form class="row" @submit.prevent="addAttributes">
<div class="medium-12 columns">
<label :class="{ error: $v.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="$v.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
</label>
<woot-input
v-model="displayName"
:label="$t('ATTRIBUTES_MGMT.ADD.FORM.NAME.LABEL')"
@ -22,7 +33,7 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.LABEL') }}
<textarea
v-model="description"
rows="5"
rows="3"
type="text"
:placeholder="$t('ATTRIBUTES_MGMT.ADD.FORM.DESC.PLACEHOLDER')"
@blur="$v.description.$touch"
@ -31,18 +42,6 @@
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.DESC.ERROR') }}
</span>
</label>
<label :class="{ error: $v.attributeModel.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.LABEL') }}
<select v-model="attributeModel">
<option v-for="model in models" :key="model.id" :value="model.id">
{{ model.option }}
</option>
</select>
<span v-if="$v.attributeModel.$error" class="message">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.MODEL.ERROR') }}
</span>
</label>
<label :class="{ error: $v.attributeType.$error }">
{{ $t('ATTRIBUTES_MGMT.ADD.FORM.TYPE.LABEL') }}
<select v-model="attributeType">

View file

@ -10,7 +10,7 @@
/>
</woot-tabs>
<div class="columns with-right-space ">
<div class="columns with-right-space">
<p
v-if="!uiFlags.isFetching && !attributes.length"
class="no-items-error-message"
@ -135,10 +135,6 @@ export default {
key: 0,
name: this.$t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
},
{
key: 1,
name: this.$t('ATTRIBUTES_MGMT.TABS.CONTACT'),
},
];
},
deleteConfirmText() {

View file

@ -3,10 +3,6 @@ export const ATTRIBUTE_MODELS = [
id: 0,
option: 'Conversation',
},
{
id: 1,
option: 'Contact',
},
];
export const ATTRIBUTE_TYPES = [
@ -18,14 +14,6 @@ export const ATTRIBUTE_TYPES = [
id: 1,
option: 'Number',
},
{
id: 2,
option: 'Currency',
},
{
id: 3,
option: 'Percent',
},
{
id: 4,
option: 'Link',

View file

@ -10,6 +10,7 @@ import profile from './profile/profile.routes';
import reports from './reports/reports.routes';
import campaigns from './campaigns/campaigns.routes';
import teams from './teams/teams.routes';
import attributes from './attributes/attributes.routes';
import store from '../../../store';
export default {
@ -36,5 +37,6 @@ export default {
...teams.routes,
...campaigns.routes,
...integrationapps.routes,
...attributes.routes,
],
};

View file

@ -278,6 +278,25 @@ const actions = {
throw new Error(error);
}
},
updateCustomAttributes: async (
{ commit },
{ conversationId, customAttributes }
) => {
try {
const response = await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
const { custom_attributes } = response.data;
commit(
types.default.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES,
custom_attributes
);
} catch (error) {
// Handle error
}
},
};
export default actions;

View file

@ -69,6 +69,14 @@ export const mutations = {
Vue.set(chat.meta, 'team', team);
},
[types.default.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](
_state,
custom_attributes
) {
const [chat] = getSelectedChatConversation(_state);
Vue.set(chat, 'custom_attributes', custom_attributes);
},
[types.default.CHANGE_CONVERSATION_STATUS](
_state,
{ conversationId, status, snoozedUntil }

View file

@ -281,4 +281,25 @@ describe('#deleteMessage', () => {
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
});
describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({
data: { custom_attributes: { order_d: '1001' } },
});
await actions.updateCustomAttributes(
{ commit },
{
conversationId: 1,
customAttributes: { order_d: '1001' },
}
);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES,
{ order_d: '1001' },
],
]);
});
});
});

View file

@ -186,5 +186,19 @@ describe('#mutations', () => {
},
]);
});
describe('#UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES', () => {
it('update conversation custom attributes', () => {
const custom_attributes = { order_id: 1001 };
const state = { allConversations: [{ id: 1 }], selectedChatId: 1 };
mutations[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](state, {
conversationId: 1,
custom_attributes,
});
expect(
state.allConversations[0].custom_attributes.custom_attributes
).toEqual(custom_attributes);
});
});
});
});

View file

@ -37,6 +37,8 @@ export default {
MARK_MESSAGE_READ: 'MARK_MESSAGE_READ',
SET_PREVIOUS_CONVERSATIONS: 'SET_PREVIOUS_CONVERSATIONS',
SET_ACTIVE_INBOX: 'SET_ACTIVE_INBOX',
UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES:
'UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES',
SET_CONVERSATION_CAN_REPLY: 'SET_CONVERSATION_CAN_REPLY',

View file

@ -3,4 +3,5 @@ export const BUS_EVENTS = {
SET_TWEET_REPLY: 'SET_TWEET_REPLY',
SHOW_ALERT: 'SHOW_ALERT',
START_NEW_CONVERSATION: 'START_NEW_CONVERSATION',
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
};