feat: Custom attribute sidebar list UX improvements (#9070)

---------

Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Sivin Varghese
2024-04-09 09:30:49 +05:30
committed by GitHub
parent c4e111b554
commit 8fe3c91813
16 changed files with 223 additions and 625 deletions

View File

@@ -11,6 +11,7 @@ body {
'Segoe UI', 'Segoe UI',
Roboto, Roboto,
'Helvetica Neue', 'Helvetica Neue',
Tahoma,
Arial, Arial,
sans-serif !important; sans-serif !important;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;

View File

@@ -118,7 +118,7 @@ button {
@apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900; @apply border border-woot-500 bg-transparent dark:bg-transparent dark:border-woot-500 text-woot-500 dark:text-woot-500 hover:bg-woot-50 dark:hover:bg-woot-900;
&.secondary { &.secondary {
@apply text-slate-700 border-slate-200 dark:border-slate-600 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700; @apply text-slate-700 border-slate-100 dark:border-slate-800 dark:text-slate-100 hover:bg-slate-50 dark:hover:bg-slate-700;
} }
&.success { &.success {

View File

@@ -2,23 +2,27 @@
<div class="py-3 px-4"> <div class="py-3 px-4">
<div class="flex items-center mb-1"> <div class="flex items-center mb-1">
<h4 class="text-sm flex items-center m-0 w-full error"> <h4 class="text-sm flex items-center m-0 w-full error">
<div v-if="isAttributeTypeCheckbox" class="checkbox-wrap"> <div v-if="isAttributeTypeCheckbox" class="flex items-center">
<input <input
v-model="editedValue" v-model="editedValue"
class="checkbox" class="!my-0 mr-2 ml-0"
type="checkbox" type="checkbox"
@change="onUpdate" @change="onUpdate"
/> />
</div> </div>
<div class="flex items-center justify-between w-full"> <div class="flex items-center justify-between w-full">
<span <span
class="attribute-name w-full text-slate-800 dark:text-slate-100 font-medium text-sm mb-0" class="w-full font-medium text-sm mb-0"
:class="{ error: $v.editedValue.$error }" :class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
: 'text-slate-800 dark:text-slate-100'
"
> >
{{ label }} {{ label }}
</span> </span>
<woot-button <woot-button
v-if="showActions" v-if="showCopyAndDeleteButton"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')" v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link" variant="link"
size="medium" size="medium"
@@ -31,7 +35,7 @@
</h4> </h4>
</div> </div>
<div v-if="notAttributeTypeCheckboxAndList"> <div v-if="notAttributeTypeCheckboxAndList">
<div v-show="isEditing"> <div v-if="isEditing" v-on-clickaway="onClickAway">
<div class="mb-2 w-full flex items-center"> <div class="mb-2 w-full flex items-center">
<input <input
ref="inputfield" ref="inputfield"
@@ -61,7 +65,7 @@
</div> </div>
<div <div
v-show="!isEditing" v-show="!isEditing"
class="value--view" class="flex group"
:class="{ 'is-editable': showActions }" :class="{ 'is-editable': showActions }"
> >
<a <a
@@ -69,35 +73,35 @@
:href="hrefURL" :href="hrefURL"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1" class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
> >
{{ urlValue }} {{ urlValue }}
</a> </a>
<p <p
v-else v-else
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1" class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
> >
{{ displayValue || '---' }} {{ displayValue || '---' }}
</p> </p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"> <div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button <woot-button
v-if="showActions" v-if="showCopyAndDeleteButton"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')" v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link" variant="link"
size="small" size="small"
color-scheme="secondary" color-scheme="secondary"
icon="clipboard" icon="clipboard"
class-names="edit-button" class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onCopy" @click="onCopy"
/> />
<woot-button <woot-button
v-if="showActions" v-if="showEditButton"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')" v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link" variant="link"
size="small" size="small"
color-scheme="secondary" color-scheme="secondary"
icon="edit" icon="edit"
class-names="edit-button" class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onEdit" @click="onEdit"
/> />
</div> </div>
@@ -126,6 +130,7 @@
</template> </template>
<script> <script>
import { mixin as clickaway } from 'vue-clickaway';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { required, url } from 'vuelidate/lib/validators'; import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents'; import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -138,7 +143,7 @@ export default {
components: { components: {
MultiselectDropdown, MultiselectDropdown,
}, },
mixins: [customAttributeMixin], mixins: [customAttributeMixin, clickaway],
props: { props: {
label: { type: String, required: true }, label: { type: String, required: true },
values: { type: Array, default: () => [] }, values: { type: Array, default: () => [] },
@@ -160,11 +165,18 @@ export default {
editedValue: null, editedValue: null,
}; };
}, },
computed: { computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() { displayValue() {
if (this.isAttributeTypeDate) { if (this.isAttributeTypeDate) {
return new Date(this.value || new Date()).toLocaleDateString(); return this.value
? new Date(this.value || new Date()).toLocaleDateString()
: '';
} }
if (this.isAttributeTypeCheckbox) { if (this.isAttributeTypeCheckbox) {
return this.value === 'false' ? false : this.value; return this.value === 'false' ? false : this.value;
@@ -230,6 +242,10 @@ export default {
this.isEditing = false; this.isEditing = false;
this.editedValue = this.formattedValue; this.editedValue = this.formattedValue;
}, },
contactId() {
// Fix to solve validation not resetting when contactId changes in contact page
this.$v.$reset();
},
}, },
validations() { validations() {
@@ -268,6 +284,10 @@ export default {
this.$refs.inputfield.focus(); this.$refs.inputfield.focus();
} }
}, },
onClickAway() {
this.$v.$reset();
this.isEditing = false;
},
onEdit() { onEdit() {
this.isEditing = true; this.isEditing = true;
this.$nextTick(() => { this.$nextTick(() => {
@@ -294,6 +314,7 @@ export default {
}, },
onDelete() { onDelete() {
this.isEditing = false; this.isEditing = false;
this.$v.$reset();
this.$emit('delete', this.attributeKey); this.$emit('delete', this.attributeKey);
}, },
onCopy() { onCopy() {
@@ -304,35 +325,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.checkbox-wrap {
@apply flex items-center;
}
.checkbox {
@apply my-0 mr-2 ml-0;
}
.attribute-name {
&.error {
@apply text-red-400 dark:text-red-500;
}
}
.edit-button {
@apply hidden;
}
.value--view {
@apply flex;
&.is-editable:hover {
.value {
@apply bg-slate-50 dark:bg-slate-700 mb-0;
}
.edit-button {
@apply block;
}
}
}
::v-deep { ::v-deep {
.selector-wrap { .selector-wrap {
@apply m-0 top-1; @apply m-0 top-1;

View File

@@ -296,6 +296,8 @@
"BUTTON": "Add custom attribute", "BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.", "NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "Copied to clipboard successfully", "COPY_SUCCESSFUL": "Copied to clipboard successfully",
"SHOW_MORE": "Show all attributes",
"SHOW_LESS": "Show less attributes",
"ACTIONS": { "ACTIONS": {
"COPY": "Copy attribute", "COPY": "Copy attribute",
"DELETE": "Delete attribute", "DELETE": "Delete attribute",

View File

@@ -29,33 +29,6 @@ export default {
conversationId() { conversationId() {
return this.currentChat.id; return this.currentChat.id;
}, },
filteredAttributes() {
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],
};
}
return {
...item,
value: this.customAttributes[key],
attribute_description: key,
attribute_display_name: key,
attribute_display_type: this.attributeDisplayType(
this.customAttributes[key]
),
attribute_key: key,
attribute_model: this.attributeType,
id: Math.random(),
};
});
},
}, },
methods: { methods: {
isAttributeNumber(attributeValue) { isAttributeNumber(attributeValue) {

View File

@@ -1,26 +0,0 @@
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: 'fluent-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
];

View File

@@ -1,7 +1,6 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import attributeMixin from '../attributeMixin'; import attributeMixin from '../attributeMixin';
import Vuex from 'vuex'; import Vuex from 'vuex';
import attributeFixtures from './attributeFixtures';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
@@ -41,43 +40,6 @@ describe('attributeMixin', () => {
expect(wrapper.vm.conversationId).toEqual(7165); 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;
},
contact() {
return {
id: 7165,
custom_attributes: {
product_id: 2021,
},
};
},
},
};
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: 'fluent-calculator',
id: 10,
updated_at: '2021-09-22T10:42:25.873Z',
value: 2021,
},
]);
});
it('return display type if attribute passed', () => { it('return display type if attribute passed', () => {
const Component = { const Component = {
render() {}, render() {},

View File

@@ -38,13 +38,10 @@
:contact-id="contact.id" :contact-id="contact.id"
attribute-type="contact_attribute" attribute-type="contact_attribute"
attribute-class="conversation--attribute" attribute-class="conversation--attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes" :custom-attributes="contact.custom_attributes"
class="even" class="even"
/> />
<custom-attribute-selector
attribute-type="contact_attribute"
:contact-id="contact.id"
/>
</accordion-item> </accordion-item>
</div> </div>
<div v-if="element.name === 'contact_labels'"> <div v-if="element.name === 'contact_labels'">
@@ -85,7 +82,6 @@ import ContactConversations from 'dashboard/routes/dashboard/conversation/Contac
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue'; import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue'; import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue'; import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
@@ -96,7 +92,6 @@ export default {
ContactInfo, ContactInfo,
ContactLabel, ContactLabel,
CustomAttributes, CustomAttributes,
CustomAttributeSelector,
draggable, draggable,
}, },
mixins: [uiSettingsMixin], mixins: [uiSettingsMixin],

View File

@@ -87,10 +87,7 @@
attribute-type="contact_attribute" attribute-type="contact_attribute"
attribute-class="conversation--attribute" attribute-class="conversation--attribute"
class="even" class="even"
:contact-id="contact.id" attribute-from="conversation_contact_panel"
/>
<custom-attribute-selector
attribute-type="contact_attribute"
:contact-id="contact.id" :contact-id="contact.id"
/> />
</accordion-item> </accordion-item>
@@ -142,7 +139,6 @@ import ConversationParticipant from './ConversationParticipant.vue';
import ContactInfo from './contact/ContactInfo.vue'; import ContactInfo from './contact/ContactInfo.vue';
import ConversationInfo from './ConversationInfo.vue'; import ConversationInfo from './ConversationInfo.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue'; import CustomAttributes from './customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue'; import MacrosList from './Macros/List.vue';
@@ -154,7 +150,6 @@ export default {
ContactInfo, ContactInfo,
ConversationInfo, ConversationInfo,
CustomAttributes, CustomAttributes,
CustomAttributeSelector,
ConversationAction, ConversationAction,
ConversationParticipant, ConversationParticipant,
draggable, draggable,

View File

@@ -1,152 +1,109 @@
<template> <script setup>
<div class="conversation--details"> import { computed } from 'vue';
<contact-details-item import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
v-if="initiatedAt"
:title="$t('CONTACT_PANEL.INITIATED_AT')"
:value="initiatedAt.timestamp"
class="conversation--attribute"
/>
<contact-details-item
v-if="browserLanguage"
:title="$t('CONTACT_PANEL.BROWSER_LANGUAGE')"
:value="browserLanguage"
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 { getLanguageName } from '../../../components/widgets/conversation/advancedFilterItems/languages';
import ContactDetailsItem from './ContactDetailsItem.vue'; import ContactDetailsItem from './ContactDetailsItem.vue';
import CustomAttributes from './customAttributes/CustomAttributes.vue'; import CustomAttributes from './customAttributes/CustomAttributes.vue';
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue'; const props = defineProps({
conversationAttributes: {
type: Object,
default: () => ({}),
},
contactAttributes: {
type: Object,
default: () => ({}),
},
});
export default { const referer = computed(() => props.conversationAttributes.referer);
components: { const initiatedAt = computed(
ContactDetailsItem, () => props.conversationAttributes.initiated_at?.timestamp
CustomAttributes, );
CustomAttributeSelector,
}, const browserInfo = props.conversationAttributes.browser;
props: {
conversationAttributes: { const browserName = computed(() => {
type: Object, if (!browserInfo) return '';
default: () => ({}), const { browser_name: name = '', browser_version: version = '' } =
}, browserInfo;
contactAttributes: { return `${name} ${version}`;
type: Object, });
default: () => ({}),
}, const browserLanguage = computed(() =>
}, getLanguageName(props.conversationAttributes.browser_language)
STATIC_ATTRIBUTES: [ );
const platformName = computed(() => {
if (!browserInfo) return '';
const { platform_name: name = '', platform_version: version = '' } =
browserInfo;
return `${name} ${version}`;
});
const createdAtIp = computed(() => props.contactAttributes.created_at_ip);
const staticElements = computed(() =>
[
{ {
name: 'initiated_at', content: initiatedAt,
label: 'CONTACT_PANEL.INITIATED_AT', title: 'CONTACT_PANEL.INITIATED_AT',
}, },
{ {
name: 'referer', content: browserLanguage,
label: 'CONTACT_PANEL.BROWSER', title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
}, },
{ {
name: 'browserName', content: referer,
label: 'CONTACT_PANEL.BROWSER', title: 'CONTACT_PANEL.INITIATED_FROM',
type: 'link',
}, },
{ {
name: 'platformName', content: browserName,
label: 'CONTACT_PANEL.OS', title: 'CONTACT_PANEL.BROWSER',
}, },
{ {
name: 'ipAddress', content: platformName,
label: 'CONTACT_PANEL.IP_ADDRESS', title: 'CONTACT_PANEL.OS',
}, },
], {
computed: { content: createdAtIp,
referer() { title: 'CONTACT_PANEL.IP_ADDRESS',
return this.conversationAttributes.referer;
}, },
initiatedAt() { ].filter(attribute => !!attribute.content.value)
return this.conversationAttributes.initiated_at; );
},
browserName() {
if (!this.conversationAttributes.browser) {
return '';
}
const {
browser_name: browserName = '',
browser_version: browserVersion = '',
} = this.conversationAttributes.browser;
return `${browserName} ${browserVersion}`;
},
browserLanguage() {
return getLanguageName(this.conversationAttributes.browser_language);
},
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> </script>
<template>
<div class="conversation--details">
<ContactDetailsItem
v-for="element in staticElements"
:key="element.title"
:title="$t(element.title)"
:value="element.content.value"
class="conversation--attribute"
>
<a
v-if="element.type === 'link'"
:href="referer"
rel="noopener noreferrer nofollow"
target="_blank"
class="text-woot-400 dark:text-woot-600"
>
{{ referer }}
</a>
</ContactDetailsItem>
<CustomAttributes
:class="staticElements.length % 2 === 0 ? 'even' : 'odd'"
attribute-class="conversation--attribute"
attribute-from="conversation_panel"
attribute-type="conversation_attribute"
/>
</div>
</template>
<style scoped lang="scss"> <style scoped lang="scss">
.conversation--attribute { .conversation--attribute {
@apply border-slate-50 dark:border-slate-700 border-b border-solid; @apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
&:nth-child(2n) { &:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800; @apply bg-slate-25 dark:bg-slate-800/50;
} }
} }
</style> </style>

View File

@@ -125,8 +125,9 @@
> >
<span <span
class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600" class="flex items-center h-10 px-2 text-sm border-solid bg-slate-50 border-y ltr:border-l rtl:border-r ltr:rounded-l-md rtl:rounded-r-md dark:bg-slate-700 text-slate-800 dark:text-slate-100 border-slate-200 dark:border-slate-600"
>{{ socialProfile.prefixURL }}</span
> >
{{ socialProfile.prefixURL }}
</span>
<input <input
v-model="socialProfileUserNames[socialProfile.key]" v-model="socialProfileUserNames[socialProfile.key]"
class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0" class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0"

View File

@@ -1,114 +0,0 @@
<template>
<div class="flex flex-col w-full max-h-[12.5rem]">
<h4
class="text-sm text-slate-800 dark:text-slate-100 mb-1 overflow-hidden whitespace-nowrap text-ellipsis flex-grow"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.TITLE') }}
</h4>
<div class="mb-2 flex-shrink-0 flex-grow-0 flex-auto max-h-8">
<input
ref="searchbar"
v-model="search"
type="text"
class="search-input"
autofocus="true"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.PLACEHOLDER')"
/>
</div>
<div
class="flex justify-start items-start flex-grow flex-shrink flex-auto overflow-auto h-32"
>
<div class="w-full h-full">
<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="w-full justify-center items-center flex mb-2 h-[70%] text-slate-500 dark:text-slate-300 py-2 px-2.5 overflow-hidden whitespace-nowrap text-ellipsis text-sm"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_SELECT.NO_RESULT') }}
</div>
<woot-button
class="float-right"
icon="add"
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',
},
contactId: { type: Number, default: null },
},
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;
},
},
mounted() {
this.focusInput();
},
methods: {
focusInput() {
this.$refs.searchbar.focus();
},
addNewAttribute() {
this.$router.push(
`/app/accounts/${this.accountId}/settings/custom-attributes/list`
);
},
async onAddAttribute(attribute) {
this.$emit('add-attribute', attribute);
},
},
};
</script>
<style lang="scss" scoped>
.search-input {
@apply m-0 w-full border border-solid border-transparent h-8 text-sm text-slate-700 dark:text-slate-100 rounded-md focus:border-woot-500 bg-slate-50 dark:bg-slate-900;
}
</style>

View File

@@ -1,79 +0,0 @@
<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

@@ -1,138 +0,0 @@
<template>
<div class="custom-attribute--selector">
<div
v-on-clickaway="closeDropdown"
class="label-wrap"
@keyup.esc="closeDropdown"
>
<woot-button
size="small"
variant="link"
icon="add"
@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"
:contact-id="contactId"
@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',
},
contactId: { type: Number, default: null },
},
data() {
return {
showAttributeDropDown: false,
};
},
methods: {
async addAttribute(attribute) {
try {
const {
attribute_key: attributeKey,
attribute_display_type: attributeDisplayType,
default_value: attributeDefaultValue,
} = attribute;
const isCheckbox = attributeDisplayType === 'checkbox';
const defaultValue = isCheckbox ? false : attributeDefaultValue || null;
if (this.attributeType === 'conversation_attribute') {
await this.$store.dispatch('updateCustomAttributes', {
conversationId: this.conversationId,
customAttributes: {
...this.customAttributes,
[attributeKey]: defaultValue,
},
});
} else {
await this.$store.dispatch('contacts/update', {
id: this.contactId,
custom_attributes: {
...this.customAttributes,
[attributeKey]: defaultValue,
},
});
}
bus.$emit(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, attributeKey);
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

@@ -1,23 +1,35 @@
<template> <template>
<div class="custom-attributes--panel"> <div class="custom-attributes--panel">
<custom-attribute <custom-attribute
v-for="attribute in filteredAttributes" v-for="attribute in displayedAttributes"
:key="attribute.id" :key="attribute.id"
:attribute-key="attribute.attribute_key" :attribute-key="attribute.attribute_key"
:attribute-type="attribute.attribute_display_type" :attribute-type="attribute.attribute_display_type"
:values="attribute.attribute_values" :values="attribute.attribute_values"
:label="attribute.attribute_display_name" :label="attribute.attribute_display_name"
:icon="attribute.icon"
emoji=""
:value="attribute.value" :value="attribute.value"
:show-actions="true" :show-actions="true"
:attribute-regex="attribute.regex_pattern" :attribute-regex="attribute.regex_pattern"
:regex-cue="attribute.regex_cue" :regex-cue="attribute.regex_cue"
:class="attributeClass" :class="attributeClass"
:contact-id="contactId"
@update="onUpdate" @update="onUpdate"
@delete="onDelete" @delete="onDelete"
@copy="onCopy" @copy="onCopy"
/> />
<!-- Show more and show less buttons show it if the filteredAttributes length is greater than 5 -->
<div v-if="filteredAttributes.length > 5" class="flex px-2 py-2">
<woot-button
size="small"
:icon="showAllAttributes ? 'chevron-up' : 'chevron-down'"
variant="clear"
color-scheme="primary"
class="!px-2 hover:!bg-transparent dark:hover:!bg-transparent"
@click="onClickToggle"
>
{{ toggleButtonText }}
</woot-button>
</div>
</div> </div>
</template> </template>
@@ -25,13 +37,14 @@
import CustomAttribute from 'dashboard/components/CustomAttribute.vue'; import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import attributeMixin from 'dashboard/mixins/attributeMixin'; import attributeMixin from 'dashboard/mixins/attributeMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
export default { export default {
components: { components: {
CustomAttribute, CustomAttribute,
}, },
mixins: [alertMixin, attributeMixin], mixins: [alertMixin, attributeMixin, uiSettingsMixin],
props: { props: {
attributeType: { attributeType: {
type: String, type: String,
@@ -42,8 +55,67 @@ export default {
default: '', default: '',
}, },
contactId: { type: Number, default: null }, contactId: { type: Number, default: null },
attributeFrom: {
type: String,
required: true,
},
},
data() {
return {
showAllAttributes: false,
};
},
computed: {
toggleButtonText() {
return !this.showAllAttributes
? this.$t('CUSTOM_ATTRIBUTES.SHOW_MORE')
: this.$t('CUSTOM_ATTRIBUTES.SHOW_LESS');
},
filteredAttributes() {
return this.attributes.map(attribute => {
// Check if the attribute key exists in customAttributes
const hasValue = Object.hasOwnProperty.call(
this.customAttributes,
attribute.attribute_key
);
const isCheckbox = attribute.attribute_display_type === 'checkbox';
const defaultValue = isCheckbox ? false : '';
return {
...attribute,
// Set value from customAttributes if it exists, otherwise use default value
value: hasValue
? this.customAttributes[attribute.attribute_key]
: defaultValue,
};
});
},
displayedAttributes() {
// Show only the first 5 attributes or all depending on showAllAttributes
if (this.showAllAttributes || this.filteredAttributes.length <= 5) {
return this.filteredAttributes;
}
return this.filteredAttributes.slice(0, 5);
},
showMoreUISettingsKey() {
return `show_all_attributes_${this.attributeFrom}`;
},
},
mounted() {
this.initializeSettings();
}, },
methods: { methods: {
initializeSettings() {
this.showAllAttributes =
this.uiSettings[this.showMoreUISettingsKey] || false;
},
onClickToggle() {
this.showAllAttributes = !this.showAllAttributes;
this.updateUISettings({
[this.showMoreUISettingsKey]: this.showAllAttributes,
});
},
async onUpdate(key, value) { async onUpdate(key, value) {
const updatedAttributes = { ...this.customAttributes, [key]: value }; const updatedAttributes = { ...this.customAttributes, [key]: value };
try { try {
@@ -96,16 +168,17 @@ export default {
}, },
}; };
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.custom-attributes--panel { .custom-attributes--panel {
.conversation--attribute { .conversation--attribute {
@apply border-slate-50 dark:border-slate-700 border-b border-solid; @apply border-slate-50 dark:border-slate-700/50 border-b border-solid;
} }
&.odd { &.odd {
.conversation--attribute { .conversation--attribute {
&:nth-child(2n + 1) { &:nth-child(2n + 1) {
@apply bg-slate-25 dark:bg-slate-800; @apply bg-slate-25 dark:bg-slate-800/50;
} }
} }
} }
@@ -113,7 +186,7 @@ export default {
&.even { &.even {
.conversation--attribute { .conversation--attribute {
&:nth-child(2n) { &:nth-child(2n) {
@apply bg-slate-25 dark:bg-slate-800; @apply bg-slate-25 dark:bg-slate-800/50;
} }
} }
} }

View File

@@ -7,10 +7,10 @@
<woot-button <woot-button
variant="hollow" variant="hollow"
color-scheme="secondary" color-scheme="secondary"
class="w-full border border-solid border-slate-200 dark:border-slate-700 px-2.5 hover:border-slate-75 dark:hover:border-slate-600" class="w-full border border-solid border-slate-100 dark:border-slate-700 px-2 hover:border-slate-75 dark:hover:border-slate-600"
@click="toggleDropdown" @click="toggleDropdown"
> >
<div class="flex"> <div class="flex gap-1">
<Thumbnail <Thumbnail
v-if="hasValue && hasThumbnail" v-if="hasValue && hasThumbnail"
:src="selectedItem.thumbnail" :src="selectedItem.thumbnail"
@@ -21,19 +21,22 @@
<div class="flex justify-between w-full min-w-0 items-center"> <div class="flex justify-between w-full min-w-0 items-center">
<h4 <h4
v-if="!hasValue" v-if="!hasValue"
class="mt-0 mb-0 mr-2 ml-0 text-ellipsis text-sm text-slate-800 dark:text-slate-100" class="text-ellipsis text-sm text-slate-800 dark:text-slate-100"
> >
{{ multiselectorPlaceholder }} {{ multiselectorPlaceholder }}
</h4> </h4>
<h4 <h4
v-else v-else
class="items-center leading-tight my-0 mx-2 overflow-hidden whitespace-nowrap text-ellipsis text-sm text-slate-800 dark:text-slate-100" class="items-center leading-tight overflow-hidden whitespace-nowrap text-ellipsis text-sm text-slate-800 dark:text-slate-100"
:title="selectedItem.name" :title="selectedItem.name"
> >
{{ selectedItem.name }} {{ selectedItem.name }}
</h4> </h4>
<i v-if="showSearchDropdown" class="icon ion-chevron-up" /> <i
<i v-else class="icon ion-chevron-down" /> v-if="showSearchDropdown"
class="icon ion-chevron-up text-slate-600 mr-1"
/>
<i v-else class="icon ion-chevron-down text-slate-600 mr-1" />
</div> </div>
</div> </div>
</woot-button> </woot-button>
@@ -137,6 +140,7 @@ export default {
}, },
}; };
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.dropdown-pane { .dropdown-pane {
@apply box-border top-[2.625rem] w-full; @apply box-border top-[2.625rem] w-full;