Merge branch 'develop' into feat/message-bubble

This commit is contained in:
Shivam Mishra
2024-12-12 18:49:12 +05:30
committed by GitHub
96 changed files with 1319 additions and 4068 deletions
@@ -6,6 +6,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Flag from 'dashboard/components-next/flag/Flag.vue';
import countries from 'shared/constants/countries';
const props = defineProps({
@@ -56,13 +57,19 @@ const countryDetails = computed(() => {
if (!activeCountry) return null;
const parts = [
activeCountry.emoji,
city ? `${city},` : null,
activeCountry.name,
].filter(Boolean);
return {
countryCode: activeCountry.id,
city: city ? `${city},` : null,
name: activeCountry.name,
};
});
return parts.length ? parts.join(' ') : null;
const formattedLocation = computed(() => {
if (!countryDetails.value) return '';
return [countryDetails.value.city, countryDetails.value.name]
.filter(Boolean)
.join(' ');
});
const handleFormUpdate = updatedData => {
@@ -114,8 +121,12 @@ const onClickViewDetails = () => emit('showContact', props.id);
{{ phoneNumber }}
</span>
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
<span v-if="countryDetails" class="text-sm truncate text-n-slate-11">
{{ countryDetails }}
<span
v-if="countryDetails"
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
>
<Flag :country="countryDetails.countryCode" class="size-3.5" />
{{ formattedLocation }}
</span>
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
<Button
@@ -106,10 +106,15 @@ const onImport = async file => {
try {
await store.dispatch('contacts/import', file);
contactImportDialogRef.value?.dialogRef.close();
useAlert(t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useAlert(
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.SUCCESS_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message ?? t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useAlert(
error.message ??
t('CONTACTS_LAYOUT.HEADER.ACTIONS.IMPORT_CONTACT.ERROR_MESSAGE')
);
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
};
@@ -70,6 +70,10 @@ const updateContact = async () => {
try {
const { customAttributes, ...basicContactData } = contactData.value;
await store.dispatch('contacts/update', basicContactData);
await store.dispatch(
'contacts/fetchContactableInbox',
props.selectedContact.id
);
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.ERROR_MESSAGE'));
@@ -153,7 +153,6 @@ watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
// Add null check for contactInboxes
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
@@ -3,6 +3,15 @@ import { getInboxIconByType } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
import ContactAPI from 'dashboard/api/contacts';
const CHANNEL_PRIORITY = {
'Channel::Email': 1,
'Channel::Whatsapp': 2,
'Channel::Sms': 3,
'Channel::TwilioSms': 4,
'Channel::WebWidget': 5,
'Channel::Api': 6,
};
export const generateLabelForContactableInboxesList = ({
name,
email,
@@ -21,27 +30,49 @@ export const generateLabelForContactableInboxesList = ({
return name;
};
const transformInbox = ({
name,
id,
email,
channelType,
phoneNumber,
...rest
}) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
});
export const compareInboxes = (a, b) => {
// Channels that have no priority defined should come at the end.
const priorityA = CHANNEL_PRIORITY[a.channelType] || 999;
const priorityB = CHANNEL_PRIORITY[b.channelType] || 999;
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
const nameA = a.name || '';
const nameB = b.name || '';
return nameA.localeCompare(nameB);
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
icon: getInboxIconByType(channelType, phoneNumber, 'line'),
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
})
);
return contactInboxes.map(transformInbox).sort(compareInboxes);
};
export const getCapitalizedNameFromEmail = email => {
@@ -463,3 +463,74 @@ describe('composeConversationHelper', () => {
});
});
});
describe('compareInboxes', () => {
it('should sort inboxes by channel priority', () => {
const inboxes = [
{ channelType: 'Channel::Api', name: 'API Inbox' },
{ channelType: 'Channel::Email', name: 'Email Inbox' },
{ channelType: 'Channel::WebWidget', name: 'Widget' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Email');
expect(sorted[1].channelType).toBe('Channel::Whatsapp');
expect(sorted[2].channelType).toBe('Channel::WebWidget');
expect(sorted[3].channelType).toBe('Channel::Api');
});
it('should sort SMS channels correctly', () => {
const inboxes = [
{ channelType: 'Channel::TwilioSms', name: 'Twilio' },
{ channelType: 'Channel::Sms', name: 'Regular SMS' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted[0].channelType).toBe('Channel::Sms');
expect(sorted[1].channelType).toBe('Channel::TwilioSms');
});
it('should sort by name when channel types are same', () => {
const inboxes = [
{ channelType: 'Channel::Email', name: 'Support' },
{ channelType: 'Channel::Email', name: 'Marketing' },
{ channelType: 'Channel::Email', name: 'Billing' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(inbox => inbox.name)).toEqual([
'Billing',
'Marketing',
'Support',
]);
});
it('should put channels without priority at the end', () => {
const inboxes = [
{ channelType: 'Channel::Unknown', name: 'Unknown' },
{ channelType: 'Channel::Email', name: 'Email' },
{ channelType: 'Channel::LineChannel', name: 'Line' },
{ channelType: 'Channel::Whatsapp', name: 'WhatsApp' },
];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted.map(i => i.channelType)).toEqual([
'Channel::Email',
'Channel::Whatsapp',
'Channel::LineChannel',
'Channel::Unknown',
]);
});
it('should handle empty array', () => {
const inboxes = [];
const sorted = [...inboxes].sort(helpers.compareInboxes);
expect(sorted).toEqual([]);
});
});
@@ -0,0 +1,60 @@
<script setup>
import { ref } from 'vue';
import Copilot from './Copilot.vue';
const supportAgent = {
available_name: 'Pranav Raj',
avatar_url:
'https://app.chatwoot.com/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBd3FodGc9PSIsImV4cCI6bnVsbCwicHVyIjoiYmxvYl9pZCJ9fQ==--d218a325af0ef45061eefd352f8efb9ac84275e8/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lKYW5CbFp3WTZCa1ZVT2hOeVpYTnBlbVZmZEc5ZlptbHNiRnNIYVFINk1BPT0iLCJleHAiOm51bGwsInB1ciI6InZhcmlhdGlvbiJ9fQ==--533c3ad7218e24c4b0e8f8959dc1953ce1d279b9/1707423736896.jpeg',
};
const messages = ref([
{
id: 1,
role: 'user',
content: 'Hi there! How can I help you today?',
},
{
id: 2,
role: 'assistant',
content:
"Hello! I'm the AI assistant. I'll be helping the support team today.",
},
]);
const isCaptainTyping = ref(false);
const sendMessage = message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
// Simulate AI response
isCaptainTyping.value = true;
setTimeout(() => {
isCaptainTyping.value = false;
messages.value.push({
id: messages.value.length + 1,
role: 'assistant',
content: 'This is a simulated AI response.',
});
}, 2000);
};
</script>
<template>
<Story
title="Captain/Copilot"
:layout="{ type: 'grid', width: '400px', height: '800px' }"
>
<Copilot
:support-agent="supportAgent"
:messages="messages"
:is-captain-typing="isCaptainTyping"
@send-message="sendMessage"
/>
</Story>
</template>
@@ -0,0 +1,68 @@
<script setup>
import CopilotInput from './CopilotInput.vue';
import CopilotLoader from './CopilotLoader.vue';
import CopilotAgentMessage from './CopilotAgentMessage.vue';
import CopilotAssistantMessage from './CopilotAssistantMessage.vue';
import { nextTick, ref, watch } from 'vue';
const props = defineProps({
supportAgent: {
type: Object,
default: () => ({}),
},
messages: {
type: Array,
default: () => [],
},
isCaptainTyping: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['sendMessage']);
const COPILOT_USER_ROLES = ['assistant', 'system'];
const sendMessage = message => {
emit('sendMessage', message);
};
const chatContainer = ref(null);
const scrollToBottom = async () => {
await nextTick();
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
}
};
watch(
[() => props.messages, () => props.isCaptainTyping],
() => {
scrollToBottom();
},
{ deep: true }
);
</script>
<template>
<div class="flex flex-col ]mx-auto h-full text-sm leading-6 tracking-tight">
<div ref="chatContainer" class="flex-1 overflow-y-auto py-4 space-y-6 px-4">
<template v-for="message in messages" :key="message.id">
<CopilotAgentMessage
v-if="message.role === 'user'"
:support-agent="supportAgent"
:message="message"
/>
<CopilotAssistantMessage
v-else-if="COPILOT_USER_ROLES.includes(message.role)"
:message="message"
/>
</template>
<CopilotLoader v-if="isCaptainTyping" />
</div>
<CopilotInput class="mx-3 mb-4 mt-px" @send="sendMessage" />
</div>
</template>
@@ -0,0 +1,31 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
supportAgent: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
:name="supportAgent.available_name"
:src="supportAgent.avatar_url"
:size="24"
rounded-full
/>
<div class="space-y-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.COPILOT.YOU') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,27 @@
<script setup>
import Avatar from '../avatar/Avatar.vue';
defineProps({
message: {
type: Object,
required: true,
},
});
</script>
<template>
<div class="flex flex-row gap-2">
<Avatar
name="Captain Copilot"
icon-name="i-woot-captain"
:size="24"
rounded-full
/>
<div class="flex flex-col gap-1 text-n-slate-12">
<div class="font-medium">{{ $t('CAPTAIN.NAME') }}</div>
<div class="break-words">
{{ message.content }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,34 @@
<script setup>
import { ref } from 'vue';
const emit = defineEmits(['send']);
const message = ref('');
const sendMessage = () => {
if (message.value.trim()) {
emit('send', message.value);
message.value = '';
}
};
</script>
<template>
<form
class="border border-n-weak bg-n-alpha-3 rounded-lg h-12 flex"
@submit.prevent="sendMessage"
>
<input
v-model="message"
type="text"
:placeholder="$t('CAPTAIN.COPILOT.SEND_MESSAGE')"
class="w-full reset-base bg-transparent px-4 py-3 text-n-slate-11 text-sm"
@keyup.enter="sendMessage"
/>
<button
class="h-auto w-12 flex items-center justify-center text-n-slate-11"
type="submit"
>
<i class="i-ph-arrow-up" />
</button>
</form>
</template>
@@ -0,0 +1,12 @@
<script setup>
import CopilotLoader from './CopilotLoader.vue';
</script>
<template>
<Story
title="Captain/CopilotLoader"
:layout="{ type: 'grid', width: '400px', height: '800px' }"
>
<CopilotLoader />
</Story>
</template>
@@ -0,0 +1,22 @@
<script>
// Copilot Loader Component
</script>
<template>
<div class="flex justify-start">
<div class="flex items-center space-x-2">
<span class="text-n-iris-11 font-medium">
{{ $t('CAPTAIN.COPILOT.LOADER') }}
</span>
<div class="flex space-x-1">
<div
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.3s]"
/>
<div
class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce [animation-delay:-0.15s]"
/>
<div class="w-2 h-2 rounded-full bg-n-iris-9 animate-bounce" />
</div>
</div>
</div>
</template>
@@ -6,7 +6,8 @@ import { useDropdownContext } from './provider.js';
const props = defineProps({
label: { type: String, default: '' },
icon: { type: [String, Object, Function], default: '' },
link: { type: String, default: '' },
link: { type: [String, Object], default: '' },
nativeLink: { type: Boolean, default: false },
click: { type: Function, default: null },
preserveOpen: { type: Boolean, default: false },
});
@@ -18,7 +19,13 @@ defineOptions({
const { closeMenu } = useDropdownContext();
const componentIs = computed(() => {
if (props.link) return 'router-link';
if (props.link) {
if (props.nativeLink && typeof props.link === 'string') {
return 'a';
}
return 'router-link';
}
if (props.click) return 'button';
return 'div';
@@ -41,7 +48,8 @@ const triggerClick = () => {
:class="{
'hover:bg-n-alpha-2 rounded-lg w-full gap-3': !$slots.default,
}"
:href="props.link || null"
:href="componentIs === 'a' ? props.link : null"
:to="componentIs === 'router-link' ? props.link : null"
@click="triggerClick"
>
<slot>
@@ -0,0 +1,24 @@
<script setup>
import { h, defineProps } from 'vue';
const props = defineProps({
country: { type: String, required: true },
squared: { type: Boolean, default: false },
});
const renderFlag = () => {
const classes = ['fi', `fi-${props.country.toLowerCase()}`, 'flex-shrink-0'];
if (props.squared) {
classes.push('fis');
}
return h('span', { class: classes });
};
</script>
<template>
<component :is="renderFlag" />
</template>
<style>
@import 'flag-icons/css/flag-icons.min.css';
</style>
@@ -0,0 +1,93 @@
<script setup>
import { ref } from 'vue';
import Flag from '../Flag.vue';
import countries from 'shared/constants/countries';
const BasicTemplate = {
components: { Flag },
props: {
country: {
type: String,
default: 'us',
},
squared: {
type: Boolean,
default: false,
},
},
template: `
<div class="flex items-center gap-4 p-4 border rounded border-n-weak">
<Flag :country="country" :squared="squared" />
</div>
`,
};
const SizeVariants = {
components: { Flag },
setup() {
const isSquared = ref(false);
return { isSquared };
},
template: `
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" v-model="isSquared">
Squared flags
</label>
<div class="flex items-center gap-4 p-4 border rounded border-n-weak">
<Flag country="in" class="!size-4" :squared="isSquared" />
<Flag country="in" class="!size-6" :squared="isSquared" />
<Flag country="in" class="!size-8" :squared="isSquared" />
<Flag country="in" class="!size-10" :squared="isSquared" />
</div>
</div>
`,
};
const AllFlags = {
components: { Flag },
setup() {
const isSquared = ref(false);
return { countries, isSquared };
},
template: `
<div class="flex flex-col gap-4">
<label class="flex items-center gap-2">
<input type="checkbox" v-model="isSquared">
Squared flags
</label>
<div class="grid grid-cols-2 gap-4 p-4 border rounded border-n-strong md:grid-cols-3 lg:grid-cols-4">
<div
v-for="country in countries"
:key="country.id"
class="flex items-center gap-2 px-4 py-2 border rounded border-n-strong"
>
<Flag
:country="country.id"
:squared="isSquared"
class="size-6"
/>
<span class="text-sm">{{ country.name }}</span>
</div>
</div>
</div>
`,
};
</script>
<template>
<Story title="Components/Flag">
<Variant title="Basic Usage">
<BasicTemplate country="us" :squared="false" />
</Variant>
<Variant title="Size Variants">
<SizeVariants />
</Variant>
<Variant title="All Flags">
<AllFlags />
</Variant>
</Story>
</template>
@@ -1,5 +1,5 @@
<script setup>
import { computed } from 'vue';
import { computed, onMounted, nextTick } from 'vue';
import { useSidebarContext } from './provider';
import { useRoute, useRouter } from 'vue-router';
import Policy from 'dashboard/components/policy.vue';
@@ -113,6 +113,13 @@ const toggleTrigger = () => {
}
setExpandedItem(props.name);
};
onMounted(async () => {
await nextTick();
if (hasActiveChild.value) {
setExpandedItem(props.name);
}
});
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
@@ -1,7 +1,6 @@
<script setup>
import { computed } from 'vue';
import Auth from 'dashboard/api/auth';
import { useRouter } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Avatar from 'next/avatar/Avatar.vue';
@@ -21,7 +20,6 @@ defineOptions({
});
const { t } = useI18n();
const router = useRouter();
const globalConfig = useMapGetter('globalConfig/get');
const currentUser = useMapGetter('getCurrentUser');
@@ -49,9 +47,7 @@ const menuItems = computed(() => {
show: true,
label: t('SIDEBAR_ITEMS.PROFILE_SETTINGS'),
icon: 'i-lucide-user-pen',
click: () => {
router.push({ name: 'profile_settings_index' });
},
link: { name: 'profile_settings_index' },
},
{
show: true,
@@ -66,15 +62,16 @@ const menuItems = computed(() => {
show: true,
label: t('SIDEBAR_ITEMS.DOCS'),
icon: 'i-lucide-book',
click: () => {
window.open('https://www.chatwoot.com/hc/user-guide/en', '_blank');
},
link: 'https://www.chatwoot.com/hc/user-guide/en',
nativeLink: true,
target: '_blank',
},
{
show: currentUser.value.type === 'SuperAdmin',
label: t('SIDEBAR_ITEMS.SUPER_ADMIN_CONSOLE'),
icon: 'i-lucide-castle',
link: '/super_admin',
nativeLink: true,
target: '_blank',
},
{