feat: Render conversation custom attributes (#3065)
This commit is contained in:
parent
69f55a25b6
commit
ab77e03c92
32 changed files with 1233 additions and 182 deletions
|
@ -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/"
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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' },
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
220
app/javascript/dashboard/components/CustomAttribute.vue
Normal file
220
app/javascript/dashboard/components/CustomAttribute.vue
Normal 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>
|
|
@ -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": {
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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",
|
||||
|
|
56
app/javascript/dashboard/mixins/attributeMixin.js
Normal file
56
app/javascript/dashboard/mixins/attributeMixin.js
Normal 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';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
26
app/javascript/dashboard/mixins/specs/attributeFixtures.js
Normal file
26
app/javascript/dashboard/mixins/specs/attributeFixtures.js
Normal 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,
|
||||
},
|
||||
];
|
89
app/javascript/dashboard/mixins/specs/attributeMixin.spec.js
Normal file
89
app/javascript/dashboard/mixins/specs/attributeMixin.spec.js
Normal 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');
|
||||
});
|
||||
});
|
|
@ -74,8 +74,4 @@ export default {
|
|||
margin-bottom: var(--space-normal);
|
||||
color: var(--b-500);
|
||||
}
|
||||
|
||||
.conv-details--item {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -61,10 +61,6 @@ export default {
|
|||
margin-bottom: var(--space-normal);
|
||||
}
|
||||
|
||||
.conv-details--item {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.custom-attribute--row__attribute {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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="🔗"
|
||||
>
|
||||
<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
|
||||
:conversation-attributes="conversationAdditionalAttributes"
|
||||
:contact-attributes="contactAdditionalAttributes"
|
||||
>
|
||||
</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;
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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' },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue