mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-03 20:48:07 +00:00 
			
		
		
		
	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:
		@@ -11,6 +11,7 @@ body {
 | 
			
		||||
    'Segoe UI',
 | 
			
		||||
    Roboto,
 | 
			
		||||
    'Helvetica Neue',
 | 
			
		||||
    Tahoma,
 | 
			
		||||
    Arial,
 | 
			
		||||
    sans-serif !important;
 | 
			
		||||
  -moz-osx-font-smoothing: grayscale;
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
    &.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 {
 | 
			
		||||
 
 | 
			
		||||
@@ -2,23 +2,27 @@
 | 
			
		||||
  <div class="py-3 px-4">
 | 
			
		||||
    <div class="flex items-center mb-1">
 | 
			
		||||
      <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
 | 
			
		||||
            v-model="editedValue"
 | 
			
		||||
            class="checkbox"
 | 
			
		||||
            class="!my-0 mr-2 ml-0"
 | 
			
		||||
            type="checkbox"
 | 
			
		||||
            @change="onUpdate"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="flex items-center justify-between w-full">
 | 
			
		||||
          <span
 | 
			
		||||
            class="attribute-name w-full text-slate-800 dark:text-slate-100 font-medium text-sm mb-0"
 | 
			
		||||
            :class="{ error: $v.editedValue.$error }"
 | 
			
		||||
            class="w-full font-medium text-sm mb-0"
 | 
			
		||||
            :class="
 | 
			
		||||
              $v.editedValue.$error
 | 
			
		||||
                ? 'text-red-400 dark:text-red-500'
 | 
			
		||||
                : 'text-slate-800 dark:text-slate-100'
 | 
			
		||||
            "
 | 
			
		||||
          >
 | 
			
		||||
            {{ label }}
 | 
			
		||||
          </span>
 | 
			
		||||
          <woot-button
 | 
			
		||||
            v-if="showActions"
 | 
			
		||||
            v-if="showCopyAndDeleteButton"
 | 
			
		||||
            v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
 | 
			
		||||
            variant="link"
 | 
			
		||||
            size="medium"
 | 
			
		||||
@@ -31,7 +35,7 @@
 | 
			
		||||
      </h4>
 | 
			
		||||
    </div>
 | 
			
		||||
    <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">
 | 
			
		||||
          <input
 | 
			
		||||
            ref="inputfield"
 | 
			
		||||
@@ -61,7 +65,7 @@
 | 
			
		||||
      </div>
 | 
			
		||||
      <div
 | 
			
		||||
        v-show="!isEditing"
 | 
			
		||||
        class="value--view"
 | 
			
		||||
        class="flex group"
 | 
			
		||||
        :class="{ 'is-editable': showActions }"
 | 
			
		||||
      >
 | 
			
		||||
        <a
 | 
			
		||||
@@ -69,35 +73,35 @@
 | 
			
		||||
          :href="hrefURL"
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          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 }}
 | 
			
		||||
        </a>
 | 
			
		||||
        <p
 | 
			
		||||
          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 || '---' }}
 | 
			
		||||
        </p>
 | 
			
		||||
        <div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
 | 
			
		||||
          <woot-button
 | 
			
		||||
            v-if="showActions"
 | 
			
		||||
            v-if="showCopyAndDeleteButton"
 | 
			
		||||
            v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
 | 
			
		||||
            variant="link"
 | 
			
		||||
            size="small"
 | 
			
		||||
            color-scheme="secondary"
 | 
			
		||||
            icon="clipboard"
 | 
			
		||||
            class-names="edit-button"
 | 
			
		||||
            class-names="hidden group-hover:flex !w-6 flex-shrink-0"
 | 
			
		||||
            @click="onCopy"
 | 
			
		||||
          />
 | 
			
		||||
          <woot-button
 | 
			
		||||
            v-if="showActions"
 | 
			
		||||
            v-if="showEditButton"
 | 
			
		||||
            v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
 | 
			
		||||
            variant="link"
 | 
			
		||||
            size="small"
 | 
			
		||||
            color-scheme="secondary"
 | 
			
		||||
            icon="edit"
 | 
			
		||||
            class-names="edit-button"
 | 
			
		||||
            class-names="hidden group-hover:flex !w-6 flex-shrink-0"
 | 
			
		||||
            @click="onEdit"
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -126,6 +130,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import { mixin as clickaway } from 'vue-clickaway';
 | 
			
		||||
import { format, parseISO } from 'date-fns';
 | 
			
		||||
import { required, url } from 'vuelidate/lib/validators';
 | 
			
		||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
 | 
			
		||||
@@ -138,7 +143,7 @@ export default {
 | 
			
		||||
  components: {
 | 
			
		||||
    MultiselectDropdown,
 | 
			
		||||
  },
 | 
			
		||||
  mixins: [customAttributeMixin],
 | 
			
		||||
  mixins: [customAttributeMixin, clickaway],
 | 
			
		||||
  props: {
 | 
			
		||||
    label: { type: String, required: true },
 | 
			
		||||
    values: { type: Array, default: () => [] },
 | 
			
		||||
@@ -160,11 +165,18 @@ export default {
 | 
			
		||||
      editedValue: null,
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  computed: {
 | 
			
		||||
    showCopyAndDeleteButton() {
 | 
			
		||||
      return this.value && this.showActions;
 | 
			
		||||
    },
 | 
			
		||||
    showEditButton() {
 | 
			
		||||
      return !this.value && this.showActions;
 | 
			
		||||
    },
 | 
			
		||||
    displayValue() {
 | 
			
		||||
      if (this.isAttributeTypeDate) {
 | 
			
		||||
        return new Date(this.value || new Date()).toLocaleDateString();
 | 
			
		||||
        return this.value
 | 
			
		||||
          ? new Date(this.value || new Date()).toLocaleDateString()
 | 
			
		||||
          : '';
 | 
			
		||||
      }
 | 
			
		||||
      if (this.isAttributeTypeCheckbox) {
 | 
			
		||||
        return this.value === 'false' ? false : this.value;
 | 
			
		||||
@@ -230,6 +242,10 @@ export default {
 | 
			
		||||
      this.isEditing = false;
 | 
			
		||||
      this.editedValue = this.formattedValue;
 | 
			
		||||
    },
 | 
			
		||||
    contactId() {
 | 
			
		||||
      // Fix to solve validation not resetting when contactId changes in contact page
 | 
			
		||||
      this.$v.$reset();
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  validations() {
 | 
			
		||||
@@ -268,6 +284,10 @@ export default {
 | 
			
		||||
        this.$refs.inputfield.focus();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    onClickAway() {
 | 
			
		||||
      this.$v.$reset();
 | 
			
		||||
      this.isEditing = false;
 | 
			
		||||
    },
 | 
			
		||||
    onEdit() {
 | 
			
		||||
      this.isEditing = true;
 | 
			
		||||
      this.$nextTick(() => {
 | 
			
		||||
@@ -294,6 +314,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    onDelete() {
 | 
			
		||||
      this.isEditing = false;
 | 
			
		||||
      this.$v.$reset();
 | 
			
		||||
      this.$emit('delete', this.attributeKey);
 | 
			
		||||
    },
 | 
			
		||||
    onCopy() {
 | 
			
		||||
@@ -304,35 +325,6 @@ export default {
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<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 {
 | 
			
		||||
  .selector-wrap {
 | 
			
		||||
    @apply m-0 top-1;
 | 
			
		||||
 
 | 
			
		||||
@@ -296,6 +296,8 @@
 | 
			
		||||
    "BUTTON": "Add custom attribute",
 | 
			
		||||
    "NOT_AVAILABLE": "There are no custom attributes available for this contact.",
 | 
			
		||||
    "COPY_SUCCESSFUL": "Copied to clipboard successfully",
 | 
			
		||||
    "SHOW_MORE": "Show all attributes",
 | 
			
		||||
    "SHOW_LESS": "Show less attributes",
 | 
			
		||||
    "ACTIONS": {
 | 
			
		||||
      "COPY": "Copy attribute",
 | 
			
		||||
      "DELETE": "Delete attribute",
 | 
			
		||||
 
 | 
			
		||||
@@ -29,33 +29,6 @@ export default {
 | 
			
		||||
    conversationId() {
 | 
			
		||||
      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: {
 | 
			
		||||
    isAttributeNumber(attributeValue) {
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
  },
 | 
			
		||||
];
 | 
			
		||||
@@ -1,7 +1,6 @@
 | 
			
		||||
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);
 | 
			
		||||
@@ -41,43 +40,6 @@ describe('attributeMixin', () => {
 | 
			
		||||
    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', () => {
 | 
			
		||||
    const Component = {
 | 
			
		||||
      render() {},
 | 
			
		||||
 
 | 
			
		||||
@@ -38,13 +38,10 @@
 | 
			
		||||
                :contact-id="contact.id"
 | 
			
		||||
                attribute-type="contact_attribute"
 | 
			
		||||
                attribute-class="conversation--attribute"
 | 
			
		||||
                attribute-from="contact_panel"
 | 
			
		||||
                :custom-attributes="contact.custom_attributes"
 | 
			
		||||
                class="even"
 | 
			
		||||
              />
 | 
			
		||||
              <custom-attribute-selector
 | 
			
		||||
                attribute-type="contact_attribute"
 | 
			
		||||
                :contact-id="contact.id"
 | 
			
		||||
              />
 | 
			
		||||
            </accordion-item>
 | 
			
		||||
          </div>
 | 
			
		||||
          <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 ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
 | 
			
		||||
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
 | 
			
		||||
import CustomAttributeSelector from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributeSelector.vue';
 | 
			
		||||
import draggable from 'vuedraggable';
 | 
			
		||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
 | 
			
		||||
 | 
			
		||||
@@ -96,7 +92,6 @@ export default {
 | 
			
		||||
    ContactInfo,
 | 
			
		||||
    ContactLabel,
 | 
			
		||||
    CustomAttributes,
 | 
			
		||||
    CustomAttributeSelector,
 | 
			
		||||
    draggable,
 | 
			
		||||
  },
 | 
			
		||||
  mixins: [uiSettingsMixin],
 | 
			
		||||
 
 | 
			
		||||
@@ -87,10 +87,7 @@
 | 
			
		||||
                attribute-type="contact_attribute"
 | 
			
		||||
                attribute-class="conversation--attribute"
 | 
			
		||||
                class="even"
 | 
			
		||||
                :contact-id="contact.id"
 | 
			
		||||
              />
 | 
			
		||||
              <custom-attribute-selector
 | 
			
		||||
                attribute-type="contact_attribute"
 | 
			
		||||
                attribute-from="conversation_contact_panel"
 | 
			
		||||
                :contact-id="contact.id"
 | 
			
		||||
              />
 | 
			
		||||
            </accordion-item>
 | 
			
		||||
@@ -142,7 +139,6 @@ import ConversationParticipant from './ConversationParticipant.vue';
 | 
			
		||||
import ContactInfo from './contact/ContactInfo.vue';
 | 
			
		||||
import ConversationInfo from './ConversationInfo.vue';
 | 
			
		||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
 | 
			
		||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
 | 
			
		||||
import draggable from 'vuedraggable';
 | 
			
		||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
 | 
			
		||||
import MacrosList from './Macros/List.vue';
 | 
			
		||||
@@ -154,7 +150,6 @@ export default {
 | 
			
		||||
    ContactInfo,
 | 
			
		||||
    ConversationInfo,
 | 
			
		||||
    CustomAttributes,
 | 
			
		||||
    CustomAttributeSelector,
 | 
			
		||||
    ConversationAction,
 | 
			
		||||
    ConversationParticipant,
 | 
			
		||||
    draggable,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,67 +1,9 @@
 | 
			
		||||
<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="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';
 | 
			
		||||
<script setup>
 | 
			
		||||
import { computed } from 'vue';
 | 
			
		||||
import { getLanguageName } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
 | 
			
		||||
import ContactDetailsItem from './ContactDetailsItem.vue';
 | 
			
		||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
 | 
			
		||||
import CustomAttributeSelector from './customAttributes/CustomAttributeSelector.vue';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: {
 | 
			
		||||
    ContactDetailsItem,
 | 
			
		||||
    CustomAttributes,
 | 
			
		||||
    CustomAttributeSelector,
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  conversationAttributes: {
 | 
			
		||||
    type: Object,
 | 
			
		||||
    default: () => ({}),
 | 
			
		||||
@@ -70,83 +12,98 @@ export default {
 | 
			
		||||
    type: Object,
 | 
			
		||||
    default: () => ({}),
 | 
			
		||||
  },
 | 
			
		||||
  },
 | 
			
		||||
  STATIC_ATTRIBUTES: [
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const referer = computed(() => props.conversationAttributes.referer);
 | 
			
		||||
const initiatedAt = computed(
 | 
			
		||||
  () => props.conversationAttributes.initiated_at?.timestamp
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const browserInfo = props.conversationAttributes.browser;
 | 
			
		||||
 | 
			
		||||
const browserName = computed(() => {
 | 
			
		||||
  if (!browserInfo) return '';
 | 
			
		||||
  const { browser_name: name = '', browser_version: version = '' } =
 | 
			
		||||
    browserInfo;
 | 
			
		||||
  return `${name} ${version}`;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const browserLanguage = computed(() =>
 | 
			
		||||
  getLanguageName(props.conversationAttributes.browser_language)
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
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',
 | 
			
		||||
      label: 'CONTACT_PANEL.INITIATED_AT',
 | 
			
		||||
      content: initiatedAt,
 | 
			
		||||
      title: 'CONTACT_PANEL.INITIATED_AT',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'referer',
 | 
			
		||||
      label: 'CONTACT_PANEL.BROWSER',
 | 
			
		||||
      content: browserLanguage,
 | 
			
		||||
      title: 'CONTACT_PANEL.BROWSER_LANGUAGE',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'browserName',
 | 
			
		||||
      label: 'CONTACT_PANEL.BROWSER',
 | 
			
		||||
      content: referer,
 | 
			
		||||
      title: 'CONTACT_PANEL.INITIATED_FROM',
 | 
			
		||||
      type: 'link',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'platformName',
 | 
			
		||||
      label: 'CONTACT_PANEL.OS',
 | 
			
		||||
      content: browserName,
 | 
			
		||||
      title: 'CONTACT_PANEL.BROWSER',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      name: 'ipAddress',
 | 
			
		||||
      label: 'CONTACT_PANEL.IP_ADDRESS',
 | 
			
		||||
      content: platformName,
 | 
			
		||||
      title: 'CONTACT_PANEL.OS',
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  computed: {
 | 
			
		||||
    referer() {
 | 
			
		||||
      return this.conversationAttributes.referer;
 | 
			
		||||
    {
 | 
			
		||||
      content: createdAtIp,
 | 
			
		||||
      title: 'CONTACT_PANEL.IP_ADDRESS',
 | 
			
		||||
    },
 | 
			
		||||
    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}`;
 | 
			
		||||
    },
 | 
			
		||||
    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';
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
  ].filter(attribute => !!attribute.content.value)
 | 
			
		||||
);
 | 
			
		||||
</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">
 | 
			
		||||
.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) {
 | 
			
		||||
    @apply bg-slate-25 dark:bg-slate-800;
 | 
			
		||||
    @apply bg-slate-25 dark:bg-slate-800/50;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -125,8 +125,9 @@
 | 
			
		||||
      >
 | 
			
		||||
        <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"
 | 
			
		||||
          >{{ socialProfile.prefixURL }}</span
 | 
			
		||||
        >
 | 
			
		||||
          {{ socialProfile.prefixURL }}
 | 
			
		||||
        </span>
 | 
			
		||||
        <input
 | 
			
		||||
          v-model="socialProfileUserNames[socialProfile.key]"
 | 
			
		||||
          class="input-group-field ltr:rounded-l-none rtl:rounded-r-none !mb-0"
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -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>
 | 
			
		||||
@@ -1,23 +1,35 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="custom-attributes--panel">
 | 
			
		||||
    <custom-attribute
 | 
			
		||||
      v-for="attribute in filteredAttributes"
 | 
			
		||||
      v-for="attribute in displayedAttributes"
 | 
			
		||||
      :key="attribute.id"
 | 
			
		||||
      :attribute-key="attribute.attribute_key"
 | 
			
		||||
      :attribute-type="attribute.attribute_display_type"
 | 
			
		||||
      :values="attribute.attribute_values"
 | 
			
		||||
      :label="attribute.attribute_display_name"
 | 
			
		||||
      :icon="attribute.icon"
 | 
			
		||||
      emoji=""
 | 
			
		||||
      :value="attribute.value"
 | 
			
		||||
      :show-actions="true"
 | 
			
		||||
      :attribute-regex="attribute.regex_pattern"
 | 
			
		||||
      :regex-cue="attribute.regex_cue"
 | 
			
		||||
      :class="attributeClass"
 | 
			
		||||
      :contact-id="contactId"
 | 
			
		||||
      @update="onUpdate"
 | 
			
		||||
      @delete="onDelete"
 | 
			
		||||
      @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>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
@@ -25,13 +37,14 @@
 | 
			
		||||
import CustomAttribute from 'dashboard/components/CustomAttribute.vue';
 | 
			
		||||
import alertMixin from 'shared/mixins/alertMixin';
 | 
			
		||||
import attributeMixin from 'dashboard/mixins/attributeMixin';
 | 
			
		||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
 | 
			
		||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: {
 | 
			
		||||
    CustomAttribute,
 | 
			
		||||
  },
 | 
			
		||||
  mixins: [alertMixin, attributeMixin],
 | 
			
		||||
  mixins: [alertMixin, attributeMixin, uiSettingsMixin],
 | 
			
		||||
  props: {
 | 
			
		||||
    attributeType: {
 | 
			
		||||
      type: String,
 | 
			
		||||
@@ -42,8 +55,67 @@ export default {
 | 
			
		||||
      default: '',
 | 
			
		||||
    },
 | 
			
		||||
    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: {
 | 
			
		||||
    initializeSettings() {
 | 
			
		||||
      this.showAllAttributes =
 | 
			
		||||
        this.uiSettings[this.showMoreUISettingsKey] || false;
 | 
			
		||||
    },
 | 
			
		||||
    onClickToggle() {
 | 
			
		||||
      this.showAllAttributes = !this.showAllAttributes;
 | 
			
		||||
      this.updateUISettings({
 | 
			
		||||
        [this.showMoreUISettingsKey]: this.showAllAttributes,
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    async onUpdate(key, value) {
 | 
			
		||||
      const updatedAttributes = { ...this.customAttributes, [key]: value };
 | 
			
		||||
      try {
 | 
			
		||||
@@ -96,16 +168,17 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped lang="scss">
 | 
			
		||||
.custom-attributes--panel {
 | 
			
		||||
  .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 {
 | 
			
		||||
    .conversation--attribute {
 | 
			
		||||
      &: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 {
 | 
			
		||||
    .conversation--attribute {
 | 
			
		||||
      &:nth-child(2n) {
 | 
			
		||||
        @apply bg-slate-25 dark:bg-slate-800;
 | 
			
		||||
        @apply bg-slate-25 dark:bg-slate-800/50;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -7,10 +7,10 @@
 | 
			
		||||
    <woot-button
 | 
			
		||||
      variant="hollow"
 | 
			
		||||
      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"
 | 
			
		||||
    >
 | 
			
		||||
      <div class="flex">
 | 
			
		||||
      <div class="flex gap-1">
 | 
			
		||||
        <Thumbnail
 | 
			
		||||
          v-if="hasValue && hasThumbnail"
 | 
			
		||||
          :src="selectedItem.thumbnail"
 | 
			
		||||
@@ -21,19 +21,22 @@
 | 
			
		||||
        <div class="flex justify-between w-full min-w-0 items-center">
 | 
			
		||||
          <h4
 | 
			
		||||
            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 }}
 | 
			
		||||
          </h4>
 | 
			
		||||
          <h4
 | 
			
		||||
            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"
 | 
			
		||||
          >
 | 
			
		||||
            {{ selectedItem.name }}
 | 
			
		||||
          </h4>
 | 
			
		||||
          <i v-if="showSearchDropdown" class="icon ion-chevron-up" />
 | 
			
		||||
          <i v-else class="icon ion-chevron-down" />
 | 
			
		||||
          <i
 | 
			
		||||
            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>
 | 
			
		||||
    </woot-button>
 | 
			
		||||
@@ -137,6 +140,7 @@ export default {
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" scoped>
 | 
			
		||||
.dropdown-pane {
 | 
			
		||||
  @apply box-border top-[2.625rem] w-full;
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user