mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +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 |       <ContactNoteItem | ||||||
|         v-for="note in notes" |         v-for="note in notes" | ||||||
|         :key="note.id" |         :key="note.id" | ||||||
|  |         class="mx-6 py-4" | ||||||
|         :note="note" |         :note="note" | ||||||
|         :written-by="getWrittenBy(note)" |         :written-by="getWrittenBy(note)" | ||||||
|  |         allow-delete | ||||||
|         @delete="onDelete" |         @delete="onDelete" | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   | |||||||
| @@ -1,6 +1,8 @@ | |||||||
| <script setup> | <script setup> | ||||||
|  | import { useTemplateRef, onMounted, ref } from 'vue'; | ||||||
| import { useI18n } from 'vue-i18n'; | import { useI18n } from 'vue-i18n'; | ||||||
| import { dynamicTime } from 'shared/helpers/timeHelper'; | import { dynamicTime } from 'shared/helpers/timeHelper'; | ||||||
|  | import { useToggle } from '@vueuse/core'; | ||||||
| import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; | import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; | ||||||
| import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; | import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| @@ -14,39 +16,63 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   allowDelete: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   collapsible: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['delete']); | const emit = defineEmits(['delete']); | ||||||
|  | const noteContentRef = useTemplateRef('noteContentRef'); | ||||||
|  | const needsCollapse = ref(false); | ||||||
|  | const [isExpanded, toggleExpanded] = useToggle(); | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
| const { formatMessage } = useMessageFormatter(); | const { formatMessage } = useMessageFormatter(); | ||||||
|  |  | ||||||
| const handleDelete = () => { | const handleDelete = () => { | ||||||
|   emit('delete', props.note.id); |   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> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <div |   <div class="flex flex-col gap-2 border-b border-n-strong group/note"> | ||||||
|     class="flex flex-col gap-2 py-2 mx-6 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"> | ||||||
|     <div class="flex items-center justify-between"> |  | ||||||
|       <div class="flex items-center gap-1.5 py-2.5 min-w-0"> |  | ||||||
|         <Avatar |         <Avatar | ||||||
|           :name="note?.user?.name || 'Bot'" |           :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" |           :size="16" | ||||||
|           rounded-full |           rounded-full | ||||||
|         /> |         /> | ||||||
|         <div class="min-w-0 truncate"> |         <div class="min-w-0 truncate"> | ||||||
|           <span class="inline-flex items-center gap-1 text-sm text-n-slate-11"> |           <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') }} |             {{ 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> |           </span> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|       <Button |       <Button | ||||||
|  |         v-if="allowDelete" | ||||||
|         variant="faded" |         variant="faded" | ||||||
|         color="ruby" |         color="ruby" | ||||||
|         size="xs" |         size="xs" | ||||||
| @@ -56,8 +82,28 @@ const handleDelete = () => { | |||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|     <p |     <p | ||||||
|  |       ref="noteContentRef" | ||||||
|       v-dompurify-html="formatMessage(note.content || '')" |       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> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([ | |||||||
|   { name: 'macros' }, |   { name: 'macros' }, | ||||||
|   { name: 'conversation_info' }, |   { name: 'conversation_info' }, | ||||||
|   { name: 'contact_attributes' }, |   { name: 'contact_attributes' }, | ||||||
|  |   { name: 'contact_notes' }, | ||||||
|   { name: 'previous_conversation' }, |   { name: 'previous_conversation' }, | ||||||
|   { name: 'conversation_participants' }, |   { name: 'conversation_participants' }, | ||||||
|   { name: 'shopify_orders' }, |   { name: 'shopify_orders' }, | ||||||
|   | |||||||
| @@ -545,6 +545,9 @@ | |||||||
|         "WROTE": "wrote", |         "WROTE": "wrote", | ||||||
|         "YOU": "You", |         "YOU": "You", | ||||||
|         "SAVE": "Save note", |         "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." |         "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_ACTIONS": "Conversation Actions", | ||||||
|       "CONVERSATION_LABELS": "Conversation Labels", |       "CONVERSATION_LABELS": "Conversation Labels", | ||||||
|       "CONVERSATION_INFO": "Conversation Information", |       "CONVERSATION_INFO": "Conversation Information", | ||||||
|  |       "CONTACT_NOTES": "Contact Notes", | ||||||
|       "CONTACT_ATTRIBUTES": "Contact Attributes", |       "CONTACT_ATTRIBUTES": "Contact Attributes", | ||||||
|       "PREVIOUS_CONVERSATION": "Previous Conversations", |       "PREVIOUS_CONVERSATION": "Previous Conversations", | ||||||
|       "MACROS": "Macros", |       "MACROS": "Macros", | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ import ConversationAction from './ConversationAction.vue'; | |||||||
| import ConversationParticipant from './ConversationParticipant.vue'; | import ConversationParticipant from './ConversationParticipant.vue'; | ||||||
|  |  | ||||||
| import ContactInfo from './contact/ContactInfo.vue'; | import ContactInfo from './contact/ContactInfo.vue'; | ||||||
|  | import ContactNotes from './contact/ContactNotes.vue'; | ||||||
| import ConversationInfo from './ConversationInfo.vue'; | import ConversationInfo from './ConversationInfo.vue'; | ||||||
| import CustomAttributes from './customAttributes/CustomAttributes.vue'; | import CustomAttributes from './customAttributes/CustomAttributes.vue'; | ||||||
| import Draggable from 'vuedraggable'; | import Draggable from 'vuedraggable'; | ||||||
| @@ -245,6 +246,18 @@ onMounted(() => { | |||||||
|                 <ShopifyOrdersList :contact-id="contactId" /> |                 <ShopifyOrdersList :contact-id="contactId" /> | ||||||
|               </AccordionItem> |               </AccordionItem> | ||||||
|             </div> |             </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> |           </div> | ||||||
|         </template> |         </template> | ||||||
|       </Draggable> |       </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