mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-11-04 04:57:51 +00:00 
			
		
		
		
	feat: add UI for contact notes (#11358)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
		@@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents);
 | 
			
		||||
      <ContactNoteItem
 | 
			
		||||
        v-for="note in notes"
 | 
			
		||||
        :key="note.id"
 | 
			
		||||
        class="mx-6 py-4"
 | 
			
		||||
        :note="note"
 | 
			
		||||
        :written-by="getWrittenBy(note)"
 | 
			
		||||
        allow-delete
 | 
			
		||||
        @delete="onDelete"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { useTemplateRef, onMounted, ref } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { dynamicTime } from 'shared/helpers/timeHelper';
 | 
			
		||||
import { useToggle } from '@vueuse/core';
 | 
			
		||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
 | 
			
		||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
 | 
			
		||||
import Button from 'dashboard/components-next/button/Button.vue';
 | 
			
		||||
@@ -14,39 +16,63 @@ const props = defineProps({
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
  allowDelete: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: false,
 | 
			
		||||
  },
 | 
			
		||||
  collapsible: {
 | 
			
		||||
    type: Boolean,
 | 
			
		||||
    default: false,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['delete']);
 | 
			
		||||
 | 
			
		||||
const noteContentRef = useTemplateRef('noteContentRef');
 | 
			
		||||
const needsCollapse = ref(false);
 | 
			
		||||
const [isExpanded, toggleExpanded] = useToggle();
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
const { formatMessage } = useMessageFormatter();
 | 
			
		||||
 | 
			
		||||
const handleDelete = () => {
 | 
			
		||||
  emit('delete', props.note.id);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  if (props.collapsible) {
 | 
			
		||||
    // Check if content height exceeds approximately 4 lines
 | 
			
		||||
    // Assuming line height is ~1.625 and font size is ~14px
 | 
			
		||||
    const threshold = 14 * 1.625 * 4; // ~84px
 | 
			
		||||
    needsCollapse.value = noteContentRef.value?.clientHeight > threshold;
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div
 | 
			
		||||
    class="flex flex-col gap-2 py-2 mx-6 border-b border-n-strong group/note"
 | 
			
		||||
  >
 | 
			
		||||
    <div class="flex items-center justify-between">
 | 
			
		||||
      <div class="flex items-center gap-1.5 py-2.5 min-w-0">
 | 
			
		||||
  <div class="flex flex-col gap-2 border-b border-n-strong group/note">
 | 
			
		||||
    <div class="flex items-center justify-between gap-2">
 | 
			
		||||
      <div class="flex items-center gap-1.5 min-w-0">
 | 
			
		||||
        <Avatar
 | 
			
		||||
          :name="note?.user?.name || 'Bot'"
 | 
			
		||||
          :src="note?.user?.thumbnail || '/assets/images/chatwoot_bot.png'"
 | 
			
		||||
          :src="
 | 
			
		||||
            note?.user?.name
 | 
			
		||||
              ? note?.user?.thumbnail
 | 
			
		||||
              : '/assets/images/chatwoot_bot.png'
 | 
			
		||||
          "
 | 
			
		||||
          :size="16"
 | 
			
		||||
          rounded-full
 | 
			
		||||
        />
 | 
			
		||||
        <div class="min-w-0 truncate">
 | 
			
		||||
          <span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
 | 
			
		||||
            <span class="font-medium">{{ writtenBy }}</span>
 | 
			
		||||
            <span class="font-medium text-n-slate-12">{{ writtenBy }}</span>
 | 
			
		||||
            {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.WROTE') }}
 | 
			
		||||
            <span class="font-medium">{{ dynamicTime(note.createdAt) }}</span>
 | 
			
		||||
            <span class="font-medium text-n-slate-12">
 | 
			
		||||
              {{ dynamicTime(note.createdAt) }}
 | 
			
		||||
            </span>
 | 
			
		||||
          </span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <Button
 | 
			
		||||
        v-if="allowDelete"
 | 
			
		||||
        variant="faded"
 | 
			
		||||
        color="ruby"
 | 
			
		||||
        size="xs"
 | 
			
		||||
@@ -56,8 +82,28 @@ const handleDelete = () => {
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
    <p
 | 
			
		||||
      ref="noteContentRef"
 | 
			
		||||
      v-dompurify-html="formatMessage(note.content || '')"
 | 
			
		||||
      class="mb-0 prose-sm prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
 | 
			
		||||
      class="mb-0 prose-sm prose-p:text-sm prose-p:leading-relaxed prose-p:mb-1 prose-p:mt-0 prose-ul:mb-1 prose-ul:mt-0 text-n-slate-12"
 | 
			
		||||
      :class="{
 | 
			
		||||
        'line-clamp-4': collapsible && !isExpanded && needsCollapse,
 | 
			
		||||
      }"
 | 
			
		||||
    />
 | 
			
		||||
    <p v-if="collapsible && needsCollapse">
 | 
			
		||||
      <Button
 | 
			
		||||
        variant="faded"
 | 
			
		||||
        color="blue"
 | 
			
		||||
        size="xs"
 | 
			
		||||
        :icon="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
 | 
			
		||||
        @click="() => toggleExpanded()"
 | 
			
		||||
      >
 | 
			
		||||
        <template v-if="isExpanded">
 | 
			
		||||
          {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.COLLAPSE') }}
 | 
			
		||||
        </template>
 | 
			
		||||
        <template v-else>
 | 
			
		||||
          {{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EXPAND') }}
 | 
			
		||||
        </template>
 | 
			
		||||
      </Button>
 | 
			
		||||
    </p>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -6,6 +6,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
 | 
			
		||||
  { name: 'macros' },
 | 
			
		||||
  { name: 'conversation_info' },
 | 
			
		||||
  { name: 'contact_attributes' },
 | 
			
		||||
  { name: 'contact_notes' },
 | 
			
		||||
  { name: 'previous_conversation' },
 | 
			
		||||
  { name: 'conversation_participants' },
 | 
			
		||||
  { name: 'shopify_orders' },
 | 
			
		||||
 
 | 
			
		||||
@@ -545,6 +545,9 @@
 | 
			
		||||
        "WROTE": "wrote",
 | 
			
		||||
        "YOU": "You",
 | 
			
		||||
        "SAVE": "Save note",
 | 
			
		||||
        "EXPAND": "Expand",
 | 
			
		||||
        "COLLAPSE": "Collapse",
 | 
			
		||||
        "NO_NOTES": "No notes, you can add notes from the contact details page.",
 | 
			
		||||
        "EMPTY_STATE": "There are no notes associated to this contact. You can add a note by typing in the box above."
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||
@@ -295,6 +295,7 @@
 | 
			
		||||
      "CONVERSATION_ACTIONS": "Conversation Actions",
 | 
			
		||||
      "CONVERSATION_LABELS": "Conversation Labels",
 | 
			
		||||
      "CONVERSATION_INFO": "Conversation Information",
 | 
			
		||||
      "CONTACT_NOTES": "Contact Notes",
 | 
			
		||||
      "CONTACT_ATTRIBUTES": "Contact Attributes",
 | 
			
		||||
      "PREVIOUS_CONVERSATION": "Previous Conversations",
 | 
			
		||||
      "MACROS": "Macros",
 | 
			
		||||
 
 | 
			
		||||
@@ -13,6 +13,7 @@ import ConversationAction from './ConversationAction.vue';
 | 
			
		||||
import ConversationParticipant from './ConversationParticipant.vue';
 | 
			
		||||
 | 
			
		||||
import ContactInfo from './contact/ContactInfo.vue';
 | 
			
		||||
import ContactNotes from './contact/ContactNotes.vue';
 | 
			
		||||
import ConversationInfo from './ConversationInfo.vue';
 | 
			
		||||
import CustomAttributes from './customAttributes/CustomAttributes.vue';
 | 
			
		||||
import Draggable from 'vuedraggable';
 | 
			
		||||
@@ -245,6 +246,18 @@ onMounted(() => {
 | 
			
		||||
                <ShopifyOrdersList :contact-id="contactId" />
 | 
			
		||||
              </AccordionItem>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div v-else-if="element.name === 'contact_notes'">
 | 
			
		||||
              <AccordionItem
 | 
			
		||||
                :title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_NOTES')"
 | 
			
		||||
                :is-open="isContactSidebarItemOpen('is_contact_notes_open')"
 | 
			
		||||
                compact
 | 
			
		||||
                @toggle="
 | 
			
		||||
                  value => toggleSidebarUIState('is_contact_notes_open', value)
 | 
			
		||||
                "
 | 
			
		||||
              >
 | 
			
		||||
                <ContactNotes :contact-id="contactId" />
 | 
			
		||||
              </AccordionItem>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </template>
 | 
			
		||||
      </Draggable>
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,51 @@
 | 
			
		||||
<script setup>
 | 
			
		||||
import { watch, computed } from 'vue';
 | 
			
		||||
import { useI18n } from 'vue-i18n';
 | 
			
		||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
 | 
			
		||||
import ContactNoteItem from 'next/Contacts/ContactsSidebar/components/ContactNoteItem.vue';
 | 
			
		||||
import Spinner from 'next/spinner/Spinner.vue';
 | 
			
		||||
 | 
			
		||||
const { contactId } = defineProps({
 | 
			
		||||
  contactId: { type: String, required: true },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const { t } = useI18n();
 | 
			
		||||
const store = useStore();
 | 
			
		||||
const currentUser = useMapGetter('getCurrentUser');
 | 
			
		||||
const uiFlags = useMapGetter('contactNotes/getUIFlags');
 | 
			
		||||
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
 | 
			
		||||
const notGetterFn = useMapGetter('contactNotes/getAllNotesByContactId');
 | 
			
		||||
const notes = computed(() => notGetterFn.value(contactId));
 | 
			
		||||
 | 
			
		||||
const getWrittenBy = ({ user } = {}) => {
 | 
			
		||||
  const currentUserId = currentUser.value?.id;
 | 
			
		||||
  return user?.id === currentUserId
 | 
			
		||||
    ? t('CONTACTS_LAYOUT.SIDEBAR.NOTES.YOU')
 | 
			
		||||
    : user?.name || t('CONVERSATION.BOT');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
watch(
 | 
			
		||||
  () => contactId,
 | 
			
		||||
  () => store.dispatch('contactNotes/get', { contactId }),
 | 
			
		||||
  { immediate: true }
 | 
			
		||||
);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="isFetchingNotes" class="p-8 grid place-content-center">
 | 
			
		||||
    <Spinner />
 | 
			
		||||
  </div>
 | 
			
		||||
  <div v-else-if="!notes.length" class="p-8 grid place-content-center">
 | 
			
		||||
    <p class="text-center">{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.NO_NOTES') }}</p>
 | 
			
		||||
  </div>
 | 
			
		||||
  <div v-else class="max-h-[300px] overflow-scroll">
 | 
			
		||||
    <ContactNoteItem
 | 
			
		||||
      v-for="note in notes"
 | 
			
		||||
      :key="note.id"
 | 
			
		||||
      class="p-4 last-of-type:border-b-0"
 | 
			
		||||
      :note="note"
 | 
			
		||||
      collapsible
 | 
			
		||||
      :written-by="getWrittenBy(note)"
 | 
			
		||||
    />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
		Reference in New Issue
	
	Block a user