mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 11:08:04 +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
	 Shivam Mishra
					Shivam Mishra