mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +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