Merge branch 'develop' into design-updates

This commit is contained in:
Pranav
2024-12-11 19:53:08 -08:00
137 changed files with 4572 additions and 4266 deletions
+8
View File
@@ -27,6 +27,14 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
show(id) {
return axios.get(`${this.url}/${id}?include_contact_inboxes=false`);
}
update(id, data) {
return axios.patch(`${this.url}/${id}?include_contact_inboxes=false`, data);
}
getConversations(contactId) {
return axios.get(`${this.url}/${contactId}/conversations`);
}
@@ -36,6 +36,10 @@ class IntegrationsAPI extends ApiClient {
requestCaptain(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/proxy`, body);
}
requestCaptainCopilot(body) {
return axios.post(`${this.baseUrl()}/integrations/captain/copilot`, body);
}
}
export default new IntegrationsAPI();
@@ -72,6 +72,19 @@
--slate-11: 96 100 108;
--slate-12: 28 32 36;
--iris-1: 253 253 255;
--iris-2: 248 248 255;
--iris-3: 240 241 254;
--iris-4: 230 231 255;
--iris-5: 218 220 255;
--iris-6: 203 205 255;
--iris-7: 184 186 248;
--iris-8: 155 158 240;
--iris-9: 91 91 214;
--iris-10: 81 81 205;
--iris-11: 87 83 198;
--iris-12: 39 41 98;
--ruby-1: 255 252 253;
--ruby-2: 255 247 248;
--ruby-3: 254 234 237;
@@ -147,6 +160,19 @@
--slate-11: 176 180 186;
--slate-12: 237 238 240;
--iris-1: 19 19 30;
--iris-2: 23 22 37;
--iris-3: 32 34 72;
--iris-4: 38 42 101;
--iris-5: 48 51 116;
--iris-6: 61 62 130;
--iris-7: 74 74 149;
--iris-8: 89 88 177;
--iris-9: 91 91 214;
--iris-10: 84 114 228;
--iris-11: 158 177 255;
--iris-12: 224 223 254;
--ruby-1: 25 17 19;
--ruby-2: 30 21 23;
--ruby-3: 58 20 30;
@@ -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
@@ -1,28 +1,30 @@
<script setup>
import { computed, useSlots } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
// import Button from 'dashboard/components-next/button/Button.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const props = defineProps({
// buttonLabel: {
// type: String,
// default: '',
// },
buttonLabel: {
type: String,
default: '',
},
selectedContact: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits([
// 'message',
'goToContactsList',
]);
const emit = defineEmits(['goToContactsList']);
const { t } = useI18n();
const slots = useSlots();
const route = useRoute();
const contactId = computed(() => route.params.contactId);
const selectedContactName = computed(() => {
return props.selectedContact?.name;
@@ -50,7 +52,7 @@ const handleBreadcrumbClick = () => {
<template>
<section
class="flex w-full h-full gap-4 overflow-hidden justify-evenly bg-n-background"
class="flex w-full h-full overflow-hidden justify-evenly bg-n-background"
>
<div
class="flex flex-col w-full h-full transition-all duration-300 ltr:2xl:ml-56 rtl:2xl:mr-56"
@@ -62,7 +64,11 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
<ComposeConversation :contact-id="contactId">
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -41,11 +41,11 @@ const FORM_CONFIG = {
};
const SOCIAL_CONFIG = {
FACEBOOK: 'i-ri-facebook-circle-fill',
GITHUB: 'i-ri-github-fill',
INSTAGRAM: 'i-ri-instagram-line',
LINKEDIN: 'i-ri-linkedin-box-fill',
FACEBOOK: 'i-ri-facebook-circle-fill',
INSTAGRAM: 'i-ri-instagram-line',
TWITTER: 'i-ri-twitter-x-fill',
GITHUB: 'i-ri-github-fill',
};
const defaultState = {
@@ -4,6 +4,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import ContactSortMenu from './components/ContactSortMenu.vue';
import ContactMoreActions from './components/ContactMoreActions.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
defineProps({
showSearch: {
@@ -18,10 +19,10 @@ defineProps({
type: String,
required: true,
},
// buttonLabel: {
// type: String,
// default: '',
// },
buttonLabel: {
type: String,
default: '',
},
activeSort: {
type: String,
default: 'last_activity_at',
@@ -48,7 +49,6 @@ const emit = defineEmits([
'search',
'filter',
'update:sort',
// 'message',
'add',
'import',
'export',
@@ -93,7 +93,7 @@ const emit = defineEmits([
"
color="slate"
size="sm"
class="relative"
class="relative w-8"
variant="ghost"
@click="emit('filter')"
>
@@ -109,7 +109,6 @@ const emit = defineEmits([
icon="i-lucide-save"
color="slate"
size="sm"
class="relative"
variant="ghost"
@click="emit('createSegment')"
/>
@@ -118,7 +117,6 @@ const emit = defineEmits([
icon="i-lucide-trash"
color="slate"
size="sm"
class="relative"
variant="ghost"
@click="emit('deleteSegment')"
/>
@@ -133,9 +131,12 @@ const emit = defineEmits([
@export="emit('export')"
/>
</div>
<!-- TODO: Add this when we enabling message feature -->
<!-- <div class="w-px h-4 bg-n-strong" /> -->
<!-- <Button :label="buttonLabel" size="sm" @click="emit('message')" /> -->
<div class="w-px h-4 bg-n-strong" />
<ComposeConversation>
<template #trigger="{ toggle }">
<Button :label="buttonLabel" size="sm" @click="toggle" />
</template>
</ComposeConversation>
</div>
</div>
</header>
@@ -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);
}
};
@@ -55,7 +55,7 @@ const handleContactAction = ({ action }) => {
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="contactMenuItems"
class="right-0 mt-1 w-52 top-full"
class="ltr:right-0 rtl:left-0 mt-1 w-52 top-full"
@action="handleContactAction($event)"
/>
</div>
@@ -109,7 +109,7 @@ const handleOrderChange = value => {
<div
v-if="isMenuOpen"
v-on-clickaway="() => (isMenuOpen = false)"
class="absolute top-full mt-1 right-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
class="absolute top-full mt-1 ltr:right-0 rtl:left-0 flex flex-col gap-4 bg-n-alpha-3 backdrop-blur-[100px] border border-n-weak w-72 rounded-xl p-4"
>
<div class="flex items-center justify-between gap-2">
<span class="text-sm text-n-slate-12">
@@ -1,28 +1,67 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import ActiveFilterPreview from 'dashboard/components-next/filter/ActiveFilterPreview.vue';
const props = defineProps({
activeSegment: { type: Object, default: null },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
const { t } = useI18n();
const route = useRoute();
const appliedFilters = useMapGetter('contacts/getAppliedContactFiltersV4');
const activeSegmentId = computed(() => route.params.segmentId);
const activeSegmentQuery = computed(() => {
const query = props.activeSegment?.query?.payload;
if (!Array.isArray(query)) return [];
const newFilters = query.map(filter => {
const transformed = useCamelCase(filter);
return {
attributeKey: transformed.attributeKey,
attributeModel: transformed.attributeModel,
customAttributeType: transformed.customAttributeType,
filterOperator: transformed.filterOperator,
queryOperator: transformed.queryOperator ?? 'and',
values: transformed.values,
};
});
return newFilters;
});
const hasActiveSegments = computed(
() => props.activeSegment && activeSegmentId.value !== 0
);
const activeFilterQueryData = computed(() => {
return hasActiveSegments.value
? activeSegmentQuery.value
: appliedFilters.value;
});
</script>
<template>
<ActiveFilterPreview
:applied-filters="appliedFilters"
:applied-filters="activeFilterQueryData"
:max-visible-filters="2"
:more-filters-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.MORE_FILTERS', {
count: appliedFilters.length - 2,
count: activeFilterQueryData.length - 2,
})
"
:clear-button-label="
t('CONTACTS_LAYOUT.FILTER.ACTIVE_FILTERS.CLEAR_FILTERS')
"
:show-clear-button="!hasActiveSegments"
class="max-w-[960px] px-6"
@open-filter="emit('openFilter')"
@clear-filters="emit('clearFilters')"
@@ -7,50 +7,18 @@ import ContactsActiveFiltersPreview from 'dashboard/components-next/Contacts/Con
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
defineProps({
searchValue: {
type: String,
default: '',
},
headerTitle: {
type: String,
default: '',
},
showPaginationFooter: {
type: Boolean,
default: true,
},
currentPage: {
type: Number,
default: 1,
},
totalItems: {
type: Number,
default: 100,
},
itemsPerPage: {
type: Number,
default: 15,
},
activeSort: {
type: String,
default: '',
},
activeOrdering: {
type: String,
default: '',
},
activeSegment: {
type: Object,
default: null,
},
segmentsId: {
type: [String, Number],
default: 0,
},
hasAppliedFilters: {
type: Boolean,
default: false,
},
searchValue: { type: String, default: '' },
headerTitle: { type: String, default: '' },
showPaginationFooter: { type: Boolean, default: true },
currentPage: { type: Number, default: 1 },
totalItems: { type: Number, default: 100 },
itemsPerPage: { type: Number, default: 15 },
activeSort: { type: String, default: '' },
activeOrdering: { type: String, default: '' },
activeSegment: { type: Object, default: null },
segmentsId: { type: [String, Number], default: 0 },
hasAppliedFilters: { type: Boolean, default: false },
isFetchingList: { type: Boolean, default: false },
});
const emit = defineEmits([
@@ -106,7 +74,12 @@ const openFilter = () => {
<main class="flex-1 overflow-y-auto">
<div class="w-full mx-auto max-w-[960px]">
<ContactsActiveFiltersPreview
v-if="hasAppliedFilters && isNotSegmentView"
v-if="
(hasAppliedFilters || !isNotSegmentView) &&
!isFetchingList &&
!isLabelView
"
:active-segment="activeSegment"
@clear-filters="emit('clearFilters')"
@open-filter="openFilter"
/>
@@ -34,22 +34,19 @@ const contactConversations = computed(() =>
>
<Spinner />
</div>
<div v-else-if="contactConversations.length > 0" class="flex flex-col py-6">
<div
<div
v-else-if="contactConversations.length > 0"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
:key="conversation.id"
class="border-b border-n-strong"
>
<ConversationCard
v-if="conversation"
:key="conversation.id"
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="px-6 !rounded-none dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
/>
</div>
:conversation="conversation"
:contact="contactsById(conversation.meta.sender.id)"
:state-inbox="stateInbox(conversation.inboxId)"
:account-labels="accountLabelsValue"
class="rounded-none hover:rounded-xl hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
/>
</div>
<p v-else class="px-6 py-10 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.HISTORY.EMPTY_STATE') }}
@@ -20,7 +20,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['goToContactsList']);
const emit = defineEmits(['goToContactsList', 'resetTab']);
const { t } = useI18n();
const store = useStore();
@@ -74,6 +74,9 @@ const onContactSearch = debounce(
);
const resetState = () => {
if (state.primaryContactId === null) {
emit('resetTab');
}
state.primaryContactId = null;
searchResults.value = [];
isSearching.value = false;
@@ -28,7 +28,7 @@ const handleDelete = () => {
<template>
<div
class="flex flex-col gap-2 px-6 py-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">
<div class="flex items-center gap-1.5 py-2.5 min-w-0">
@@ -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'));
@@ -130,15 +134,28 @@ const handleAvatarDelete = async () => {
<h3 class="text-base font-medium text-n-slate-12">
{{ selectedContact?.name }}
</h3>
<span class="text-sm text-n-slate-11">
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
<div class="flex flex-col gap-1.5">
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
>
<span class="i-ph-user-gear text-n-slate-10 size-4" />
{{ selectedContact?.identifier }}
</span>
<span class="inline-flex items-center gap-1 text-sm text-n-slate-11">
<span
v-if="selectedContact?.identifier"
class="i-ph-activity text-n-slate-10 size-4"
/>
{{ $t('CONTACTS_LAYOUT.DETAILS.CREATED_AT', { date: createdAt }) }}
{{
$t('CONTACTS_LAYOUT.DETAILS.LAST_ACTIVITY', {
date: lastActivityAt,
})
}}
</span>
</div>
</div>
<ContactLabels :contact-id="selectedContact?.id" />
</div>
@@ -82,7 +82,7 @@ const onCardClick = e => {
<template>
<div
class="flex w-full gap-3 px-3 py-4 transition-colors duration-300 ease-in-out cursor-pointer rounded-xl"
class="flex w-full gap-3 px-3 py-4 transition-all duration-300 ease-in-out cursor-pointer"
@click="onCardClick"
>
<Avatar
@@ -169,7 +169,7 @@ watch(
@apply m-0 !important;
&::before {
@apply text-n-slate-10 dark:text-n-slate-10 !important;
@apply text-n-slate-11 dark:text-n-slate-11;
}
}
}
@@ -111,7 +111,7 @@ onMounted(() => {
custom-label-class="min-w-[120px]"
/>
</div>
<div class="flex justify-between w-full gap-2 py-2">
<div class="flex justify-between w-full gap-3 py-2">
<label
class="text-sm font-medium whitespace-nowrap min-w-[120px] text-slate-900 dark:text-slate-50"
>
@@ -0,0 +1,214 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { debounce } from '@chatwoot/utils';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import {
searchContacts,
createNewContact,
fetchContactableInboxes,
processContactableInboxes,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
const props = defineProps({
alignPosition: {
type: String,
default: 'left',
},
contactId: {
type: String,
default: null,
},
});
const store = useStore();
const { t } = useI18n();
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
const isCreatingContact = ref(false);
const isFetchingInboxes = ref(false);
const isSearching = ref(false);
const showComposeNewConversation = ref(false);
const contactById = useMapGetter('contacts/getContactById');
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
const currentUser = useMapGetter('getCurrentUser');
const globalConfig = useMapGetter('globalConfig/get');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const directUploadsEnabled = computed(
() => globalConfig.value.directUploadsEnabled
);
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
: 'absolute rtl:left-0 rtl:right-[unset] ltr:right-0 ltr:left-[unset]';
});
const onContactSearch = debounce(
async query => {
isSearching.value = true;
contacts.value = [];
try {
contacts.value = await searchContacts(query);
isSearching.value = false;
} catch (error) {
useAlert(t('COMPOSE_NEW_CONVERSATION.CONTACT_SEARCH.ERROR_MESSAGE'));
} finally {
isSearching.value = false;
}
},
300,
false
);
const resetContacts = () => {
contacts.value = [];
};
const handleSelectedContact = async ({ value, action, ...rest }) => {
let contact;
if (action === 'create') {
isCreatingContact.value = true;
try {
contact = await createNewContact(value);
isCreatingContact.value = false;
} catch (error) {
isCreatingContact.value = false;
return;
}
} else {
contact = rest;
}
selectedContact.value = contact;
if (contact?.id) {
isFetchingInboxes.value = true;
try {
const contactableInboxes = await fetchContactableInboxes(contact.id);
selectedContact.value.contactInboxes = contactableInboxes;
isFetchingInboxes.value = false;
} catch (error) {
isFetchingInboxes.value = false;
}
}
};
const handleTargetInbox = inbox => {
targetInbox.value = inbox;
resetContacts();
};
const clearSelectedContact = () => {
selectedContact.value = null;
targetInbox.value = null;
};
const closeCompose = () => {
showComposeNewConversation.value = false;
selectedContact.value = null;
targetInbox.value = null;
resetContacts();
};
const createConversation = async ({ payload, isFromWhatsApp }) => {
try {
const data = await store.dispatch('contactConversations/create', {
params: payload,
isFromWhatsApp,
});
const action = {
type: 'link',
to: `/app/accounts/${data.account_id}/conversations/${data.id}`,
message: t('COMPOSE_NEW_CONVERSATION.FORM.GO_TO_CONVERSATION'),
};
closeCompose();
useAlert(t('COMPOSE_NEW_CONVERSATION.FORM.SUCCESS_MESSAGE'), action);
return true; // Return success
} catch (error) {
useAlert(
error instanceof ExceptionWithMessage
? error.data
: t('COMPOSE_NEW_CONVERSATION.FORM.ERROR_MESSAGE')
);
return false; // Return failure
}
};
const toggle = () => {
showComposeNewConversation.value = !showComposeNewConversation.value;
};
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
selectedContact.value = {
...activeContact.value,
contactInboxes: processContactableInboxes(contactInboxes),
};
}
},
{ immediate: true, deep: true }
);
onMounted(() => resetContacts());
const keyboardEvents = {
Escape: {
action: () => {
if (showComposeNewConversation.value) {
showComposeNewConversation.value = false;
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
v-on-click-outside="() => (showComposeNewConversation = false)"
class="relative z-40"
>
<slot
name="trigger"
:is-open="showComposeNewConversation"
:toggle="toggle"
/>
<ComposeNewConversationForm
v-if="showComposeNewConversation"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="isCreatingContact"
:is-fetching-inboxes="isFetchingInboxes"
:is-direct-uploads-enabled="directUploadsEnabled"
:contact-conversations-ui-flags="uiFlags"
:contacts-ui-flags="contactsUiFlags"
:class="composePopoverClass"
@search-contacts="onContactSearch"
@reset-contact-search="resetContacts"
@update-selected-contact="handleSelectedContact"
@update-target-inbox="handleTargetInbox"
@clear-selected-contact="clearSelectedContact"
@create-conversation="createConversation"
@discard="closeCompose"
/>
</div>
</template>
@@ -0,0 +1,251 @@
<script setup>
import { defineAsyncComponent, ref, computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useFileUpload } from 'dashboard/composables/useFileUpload';
import { vOnClickOutside } from '@vueuse/components';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import FileUpload from 'vue-upload-component';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({
attachedFiles: {
type: Array,
default: () => [],
},
isWhatsappInbox: {
type: Boolean,
default: false,
},
isEmailOrWebWidgetInbox: {
type: Boolean,
default: false,
},
isTwilioSmsInbox: {
type: Boolean,
default: false,
},
messageTemplates: {
type: Array,
default: () => [],
},
channelType: {
type: String,
default: '',
},
isLoading: {
type: Boolean,
default: false,
},
disableSendButton: {
type: Boolean,
default: false,
},
hasNoInbox: {
type: Boolean,
default: false,
},
isDropdownActive: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'discard',
'sendMessage',
'sendWhatsappMessage',
'insertEmoji',
'addSignature',
'removeSignature',
'attachFile',
]);
const { t } = useI18n();
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const messageSignature = useMapGetter('getMessageSignature');
const signatureToApply = computed(() => messageSignature.value);
const {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
isEditorHotKeyEnabled,
} = useUISettings();
const sendWithSignature = computed(() => {
return fetchSignatureFlagFromUISettings(props.channelType);
});
const isSignatureEnabledForInbox = computed(() => {
return props.isEmailOrWebWidgetInbox && sendWithSignature.value;
});
const setSignature = () => {
if (signatureToApply.value) {
if (isSignatureEnabledForInbox.value) {
emit('addSignature', signatureToApply.value);
} else {
emit('removeSignature', signatureToApply.value);
}
}
};
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
const onClickInsertEmoji = emoji => {
emit('insertEmoji', emoji);
};
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: props.isTwilioSmsInbox,
attachFile: ({ blob, file }) => {
if (!file) return;
const reader = new FileReader();
reader.readAsDataURL(file.file);
reader.onloadend = () => {
const newFile = {
resource: blob || file,
isPrivate: false,
thumb: reader.result,
blobSignedId: blob?.signed_id,
};
emit('attachFile', [...props.attachedFiles, newFile]);
};
},
});
const sendButtonLabel = computed(() => {
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode,
});
});
const keyboardEvents = {
Enter: {
action: () => {
if (
isEditorHotKeyEnabled('enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
'$mod+Enter': {
action: () => {
if (
isEditorHotKeyEnabled('cmd_enter') &&
!props.isWhatsappInbox &&
!props.isDropdownActive
) {
emit('sendMessage');
}
},
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex items-center justify-between w-full h-[52px] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<WhatsAppOptions
v-if="isWhatsappInbox"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
<div
v-if="!isWhatsappInbox && !hasNoInbox"
v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative"
>
<Button
icon="i-lucide-smile-plus"
color="slate"
size="sm"
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-full mt-1.5"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
v-if="isEmailOrWebWidgetInbox"
ref="uploadAttachment"
input-id="composeNewConversationAttachment"
:size="4096 * 4096"
:accept="ALLOWED_FILE_TYPES"
multiple
:drop-directory="false"
:data="{
direct_upload_url: '/rails/active_storage/direct_uploads',
direct_upload: true,
}"
class="p-px"
@input-file="onFileUpload"
>
<Button
icon="i-lucide-plus"
color="slate"
size="sm"
class="!w-10 relative"
/>
</FileUpload>
<Button
v-if="isEmailOrWebWidgetInbox"
icon="i-lucide-signature"
color="slate"
size="sm"
class="!w-10"
@click="toggleMessageSignature"
/>
</div>
<div class="flex items-center gap-2">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="emit('discard')"
/>
<Button
v-if="!isWhatsappInbox"
:label="sendButtonLabel"
size="sm"
class="!text-xs font-medium"
:disabled="isLoading || disableSendButton"
:is-loading="isLoading"
@click="emit('sendMessage')"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.emoji-dialog::before {
@apply hidden;
}
</style>
@@ -0,0 +1,88 @@
<script setup>
import { computed } from 'vue';
import { fileNameWithEllipsis } from '@chatwoot/utils';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: {
type: Array,
required: true,
},
});
const emit = defineEmits(['update:attachments']);
const isTypeImage = file => {
const type = file.content_type || file.type;
return type.includes('image');
};
const filteredImageAttachments = computed(() => {
return props.attachments.filter(attachment =>
isTypeImage(attachment.resource)
);
});
const filteredNonImageAttachments = computed(() => {
return props.attachments.filter(
attachment => !isTypeImage(attachment.resource)
);
});
const removeAttachment = id => {
const updatedAttachments = props.attachments.filter(
attachment => attachment.resource.id !== id
);
emit('update:attachments', updatedAttachments);
};
</script>
<template>
<div class="flex flex-col gap-4 p-4">
<div
v-if="filteredImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredImageAttachments"
:key="attachment.id"
class="relative group/image w-[72px] h-[72px]"
>
<img
class="object-cover w-[72px] h-[72px] rounded-lg"
:src="attachment.thumb"
/>
<Button
variant="ghost"
icon="i-lucide-trash"
color="slate"
class="absolute top-1 right-1 !w-5 !h-5 transition-opacity duration-150 ease-in-out opacity-0 group-hover/image:opacity-100"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
<div
v-if="filteredNonImageAttachments.length > 0"
class="flex flex-wrap gap-3"
>
<div
v-for="attachment in filteredNonImageAttachments"
:key="attachment.id"
class="max-w-[300px] inline-flex items-center h-8 min-w-0 bg-n-alpha-2 dark:bg-n-solid-3 rounded-lg gap-3 ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2"
>
<span class="text-sm font-medium text-n-slate-11">
{{ fileNameWithEllipsis(attachment.resource) }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="shrink-0 !h-5 !w-5"
@click="removeAttachment(attachment.resource.id)"
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,336 @@
<script setup>
import { reactive, ref, computed } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { required, requiredIf } from '@vuelidate/validators';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import {
appendSignature,
removeSignature,
} from 'dashboard/helper/editorHelper';
import {
buildContactableInboxesList,
prepareNewMessagePayload,
prepareWhatsAppMessagePayload,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import ContactSelector from './ContactSelector.vue';
import InboxSelector from './InboxSelector.vue';
import EmailOptions from './EmailOptions.vue';
import MessageEditor from './MessageEditor.vue';
import ActionButtons from './ActionButtons.vue';
import InboxEmptyState from './InboxEmptyState.vue';
import AttachmentPreviews from './AttachmentPreviews.vue';
const props = defineProps({
contacts: { type: Array, default: () => [] },
contactId: { type: String, default: null },
selectedContact: { type: Object, default: null },
targetInbox: { type: Object, default: null },
currentUser: { type: Object, default: null },
isCreatingContact: { type: Boolean, default: false },
isFetchingInboxes: { type: Boolean, default: false },
isLoading: { type: Boolean, default: false },
isDirectUploadsEnabled: { type: Boolean, default: false },
contactConversationsUiFlags: { type: Object, default: null },
contactsUiFlags: { type: Object, default: null },
});
const emit = defineEmits([
'searchContacts',
'discard',
'updateSelectedContact',
'updateTargetInbox',
'clearSelectedContact',
'createConversation',
]);
const showContactsDropdown = ref(false);
const showInboxesDropdown = ref(false);
const showCcEmailsDropdown = ref(false);
const showBccEmailsDropdown = ref(false);
const isCreating = computed(() => props.contactConversationsUiFlags.isCreating);
const state = reactive({
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
const inboxTypes = computed(() => ({
isEmail: props.targetInbox?.channelType === INBOX_TYPES.EMAIL,
isTwilio: props.targetInbox?.channelType === INBOX_TYPES.TWILIO,
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
isTwilioSMS:
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
props.targetInbox?.medium === 'sms',
}));
const whatsappMessageTemplates = computed(() =>
Object.keys(props.targetInbox?.messageTemplates || {}).length
? props.targetInbox.messageTemplates
: []
);
const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const validationRules = computed(() => ({
selectedContact: { required },
targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
subject: { required: requiredIf(inboxTypes.value.isEmail) },
}));
const v$ = useVuelidate(validationRules, {
selectedContact: computed(() => props.selectedContact),
targetInbox: computed(() => props.targetInbox),
message: computed(() => state.message),
subject: computed(() => state.subject),
});
const validationStates = computed(() => ({
isContactInvalid:
v$.value.selectedContact.$dirty && v$.value.selectedContact.$invalid,
isInboxInvalid: v$.value.targetInbox.$dirty && v$.value.targetInbox.$invalid,
isSubjectInvalid: v$.value.subject.$dirty && v$.value.subject.$invalid,
isMessageInvalid: v$.value.message.$dirty && v$.value.message.$invalid,
}));
const newMessagePayload = () => {
const { message, subject, ccEmails, bccEmails, attachedFiles } = state;
return prepareNewMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser: props.currentUser,
attachedFiles,
directUploadsEnabled: props.isDirectUploadsEnabled,
});
};
const contactableInboxesList = computed(() => {
return buildContactableInboxesList(props.selectedContact?.contactInboxes);
});
const showNoInboxAlert = computed(() => {
return (
props.selectedContact &&
contactableInboxesList.value.length === 0 &&
!props.contactsUiFlags.isFetchingInboxes &&
!props.isFetchingInboxes
);
});
const isAnyDropdownActive = computed(() => {
return (
showContactsDropdown.value ||
showInboxesDropdown.value ||
showCcEmailsDropdown.value ||
showBccEmailsDropdown.value
);
});
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
});
};
const handleDropdownUpdate = (type, value) => {
if (type === 'cc') {
showCcEmailsDropdown.value = value;
} else if (type === 'bcc') {
showBccEmailsDropdown.value = value;
} else {
showContactsDropdown.value = value;
}
};
const searchCcEmails = value => {
showCcEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', { keys: ['email'], query: value });
};
const setSelectedContact = async ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateSelectedContact', { value, action, ...rest });
showContactsDropdown.value = false;
showInboxesDropdown.value = true;
};
const handleInboxAction = ({ value, action, ...rest }) => {
v$.value.$reset();
emit('updateTargetInbox', { ...rest });
showInboxesDropdown.value = false;
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
};
const onClickInsertEmoji = emoji => {
state.message += emoji;
};
const handleAddSignature = signature => {
state.message = appendSignature(state.message, signature);
};
const handleRemoveSignature = signature => {
state.message = removeSignature(state.message, signature);
};
const handleAttachFile = files => {
state.attachedFiles = files;
};
const clearForm = () => {
Object.assign(state, {
message: '',
subject: '',
ccEmails: '',
bccEmails: '',
attachedFiles: [],
});
v$.value.$reset();
};
const handleSendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
try {
const success = await emit('createConversation', {
payload: newMessagePayload(),
isFromWhatsApp: false,
});
if (success) {
clearForm();
}
} catch (error) {
// Form will not be cleared if conversation creation fails
}
};
const handleSendWhatsappMessage = async ({ message, templateParams }) => {
const whatsappMessagePayload = prepareWhatsAppMessagePayload({
targetInbox: props.targetInbox,
selectedContact: props.selectedContact,
message,
templateParams,
currentUser: props.currentUser,
});
await emit('createConversation', {
payload: whatsappMessagePayload,
isFromWhatsApp: true,
});
};
</script>
<template>
<div
class="w-[670px] mt-2 divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
>
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isLoading"
:is-creating-contact="isCreatingContact"
:contact-id="contactId"
:contactable-inboxes-list="contactableInboxesList"
:show-inboxes-dropdown="showInboxesDropdown"
:has-errors="validationStates.isContactInvalid"
@search-contacts="handleContactSearch"
@set-selected-contact="setSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<InboxEmptyState v-if="showNoInboxAlert" />
<InboxSelector
v-else
:target-inbox="targetInbox"
:selected-contact="selectedContact"
:show-inboxes-dropdown="showInboxesDropdown"
:contactable-inboxes-list="contactableInboxesList"
:has-errors="validationStates.isInboxInvalid"
@update-inbox="removeTargetInbox"
@toggle-dropdown="showInboxesDropdown = $event"
@handle-inbox-action="handleInboxAction"
/>
<EmailOptions
v-if="inboxTypes.isEmail"
v-model:cc-emails="state.ccEmails"
v-model:bcc-emails="state.bccEmails"
v-model:subject="state.subject"
:contacts="contacts"
:show-cc-emails-dropdown="showCcEmailsDropdown"
:show-bcc-emails-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
:has-errors="validationStates.isSubjectInvalid"
@search-cc-emails="searchCcEmails"
@search-bcc-emails="searchBccEmails"
@update-dropdown="handleDropdownUpdate"
/>
<MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
v-model="state.message"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
/>
<AttachmentPreviews
v-if="state.attachedFiles.length > 0"
:attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event"
/>
<ActionButtons
:attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates"
:channel-type="inboxChannelType"
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
@insert-emoji="onClickInsertEmoji"
@add-signature="handleAddSignature"
@remove-signature="handleRemoveSignature"
@attach-file="handleAttachFile"
@discard="$emit('discard')"
@send-message="handleSendMessage"
@send-whatsapp-message="handleSendWhatsappMessage"
/>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
contacts: {
type: Array,
required: true,
},
selectedContact: {
type: Object,
default: null,
},
showContactsDropdown: {
type: Boolean,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
isCreatingContact: {
type: Boolean,
required: true,
},
contactId: {
type: String,
default: null,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'searchContacts',
'setSelectedContact',
'clearSelectedContact',
'updateDropdown',
]);
const i18nPrefix = 'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR';
const { t } = useI18n();
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: email ? `${name} (${email})` : name,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
name,
email,
action: 'contact',
}));
});
const selectedContactLabel = computed(() => {
const { name, email = '', phoneNumber = '' } = props.selectedContact || {};
if (email) {
return `${name} (${email})`;
}
if (phoneNumber) {
return `${name} (${phoneNumber})`;
}
return name || '';
});
const errorClass = computed(() => {
return props.hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
<template>
<div class="relative flex-1 px-4 py-3 overflow-y-visible">
<div class="flex items-baseline w-full gap-3 min-h-7">
<label class="text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t(`${i18nPrefix}.LABEL`) }}
</label>
<div
v-if="isCreatingContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ t(`${i18nPrefix}.CONTACT_CREATING`) }}
</span>
</div>
<div
v-else-if="selectedContact"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 px-3 min-h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{
isCreatingContact
? t(`${i18nPrefix}.CONTACT_CREATING`)
: selectedContactLabel
}}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
:disabled="contactId"
size="xs"
@click="emit('clearSelectedContact')"
/>
</div>
<TagInput
v-else
:placeholder="t(`${i18nPrefix}.TAG_INPUT_PLACEHOLDER`)"
mode="single"
:menu-items="contactsList"
:show-dropdown="showContactsDropdown"
:is-loading="isLoading"
:disabled="contactableInboxesList?.length > 0 && showInboxesDropdown"
allow-create
type="email"
class="flex-1 min-h-7"
:class="errorClass"
focus-on-mount
@input="emit('searchContacts', $event)"
@on-click-outside="emit('updateDropdown', 'contacts', false)"
@add="emit('setSelectedContact', $event)"
@remove="emit('clearSelectedContact')"
/>
</div>
</div>
</template>
@@ -0,0 +1,139 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
const props = defineProps({
contacts: { type: Array, required: true },
showCcEmailsDropdown: { type: Boolean, required: false },
showBccEmailsDropdown: { type: Boolean, required: false },
isLoading: { type: Boolean, default: false },
hasErrors: { type: Boolean, default: false },
});
const emit = defineEmits([
'searchCcEmails',
'searchBccEmails',
'updateDropdown',
]);
const i18nPrefix = `COMPOSE_NEW_CONVERSATION.FORM.EMAIL_OPTIONS`;
const showBccInput = ref(false);
const toggleBccInput = () => {
showBccInput.value = !showBccInput.value;
};
const subject = defineModel('subject', { type: String, default: '' });
const ccEmails = defineModel('ccEmails', { type: String, default: '' });
const bccEmails = defineModel('bccEmails', { type: String, default: '' });
const { t } = useI18n();
// Convert string to array for TagInput
const ccEmailsArray = computed(() =>
props.ccEmails ? props.ccEmails.split(',').map(email => email.trim()) : []
);
const bccEmailsArray = computed(() =>
props.bccEmails ? props.bccEmails.split(',').map(email => email.trim()) : []
);
const contactEmailsList = computed(() => {
return props.contacts?.map(({ name, id, email }) => ({
id,
label: email,
email,
thumbnail: { name: name, src: '' },
value: id,
action: 'email',
}));
});
// Handle updates from TagInput and convert array back to string
const handleCcUpdate = value => {
ccEmails.value = value.join(',');
};
const handleBccUpdate = value => {
bccEmails.value = value.join(',');
};
const inputClass = computed(() => {
return props.hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: '';
});
</script>
<template>
<div class="flex flex-col divide-y divide-n-strong">
<div class="flex items-baseline flex-1 w-full h-8 gap-3 px-4 py-3">
<InlineInput
v-model="subject"
:placeholder="t(`${i18nPrefix}.SUBJECT_PLACEHOLDER`)"
:label="t(`${i18nPrefix}.SUBJECT_LABEL`)"
focus-on-mount
:custom-input-class="inputClass"
/>
</div>
<div class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8">
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.CC_LABEL`) }}
</label>
<div class="flex items-center w-full gap-3 min-h-7">
<TagInput
:model-value="ccEmailsArray"
:placeholder="t(`${i18nPrefix}.CC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showCcEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
@focus="emit('updateDropdown', 'cc', true)"
@input="emit('searchCcEmails', $event)"
@on-click-outside="emit('updateDropdown', 'cc', false)"
@update:model-value="handleCcUpdate"
/>
<Button
:label="t(`${i18nPrefix}.BCC_BUTTON`)"
variant="ghost"
size="sm"
color="slate"
class="flex-shrink-0"
@click="toggleBccInput"
/>
</div>
</div>
<div
v-if="showBccInput"
class="flex items-baseline flex-1 w-full gap-3 px-4 py-3 min-h-8"
>
<label
class="mb-0.5 text-sm font-medium whitespace-nowrap text-n-slate-11"
>
{{ t(`${i18nPrefix}.BCC_LABEL`) }}
</label>
<TagInput
:model-value="bccEmailsArray"
:placeholder="t(`${i18nPrefix}.BCC_PLACEHOLDER`)"
:menu-items="contactEmailsList"
:show-dropdown="showBccEmailsDropdown"
:is-loading="isLoading"
type="email"
class="flex-1 min-h-7"
focus-on-mount
@focus="emit('updateDropdown', 'bcc', true)"
@input="emit('searchBccEmails', $event)"
@on-click-outside="emit('updateDropdown', 'bcc', false)"
@update:model-value="handleBccUpdate"
/>
</div>
</div>
</template>
@@ -0,0 +1,9 @@
<template>
<div
class="flex items-center w-full px-4 py-3 dark:bg-n-amber-11/15 bg-n-amber-3"
>
<span class="text-sm dark:text-n-amber-11 text-n-amber-11">
{{ $t('COMPOSE_NEW_CONVERSATION.FORM.NO_INBOX_ALERT') }}
</span>
</div>
</template>
@@ -0,0 +1,91 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import { generateLabelForContactableInboxesList } from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
targetInbox: {
type: Object,
default: null,
},
selectedContact: {
type: Object,
default: null,
},
showInboxesDropdown: {
type: Boolean,
required: true,
},
contactableInboxesList: {
type: Array,
default: () => [],
},
hasErrors: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'updateInbox',
'toggleDropdown',
'handleInboxAction',
]);
const { t } = useI18n();
const targetInboxLabel = computed(() => {
return generateLabelForContactableInboxesList(props.targetInbox);
});
</script>
<template>
<div
class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible"
>
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.LABEL') }}
</label>
<div
v-if="targetInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate px-3 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12">
{{ targetInboxLabel }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="emit('updateInbox', null)"
/>
</div>
<div
v-else
v-on-click-outside="() => emit('toggleDropdown', false)"
class="relative flex items-center h-7"
>
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.INBOX_SELECTOR.BUTTON')"
variant="link"
size="sm"
:color="hasErrors ? 'ruby' : 'slate'"
:disabled="!selectedContact"
class="hover:!no-underline"
@click="emit('toggleDropdown', !showInboxesDropdown)"
/>
<DropdownMenu
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
:menu-items="contactableInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="emit('handleInboxAction', $event)"
/>
</div>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup>
import { useI18n } from 'vue-i18n';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
defineProps({
isEmailOrWebWidgetInbox: {
type: Boolean,
required: true,
},
hasErrors: {
type: Boolean,
default: false,
},
hasAttachments: {
type: Boolean,
default: false,
},
});
const { t } = useI18n();
const modelValue = defineModel({
type: String,
default: '',
});
</script>
<template>
<div
v-if="isEmailOrWebWidgetInbox"
class="flex-1 h-full"
:class="!hasAttachments && 'min-h-[200px]'"
>
<Editor
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[200px]"
:class="
hasErrors
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
: ''
"
:show-character-count="false"
/>
</div>
<div v-else class="flex-1 h-full" :class="!hasAttachments && 'min-h-[200px]'">
<TextArea
v-model="modelValue"
:placeholder="
t('COMPOSE_NEW_CONVERSATION.FORM.MESSAGE_EDITOR.PLACEHOLDER')
"
class="!px-0 [&>div]:!px-4 [&>div]:!border-transparent [&>div]:!bg-transparent"
auto-height
:custom-text-area-class="
hasErrors
? 'placeholder:!text-n-ruby-9 dark:placeholder:!text-n-ruby-9'
: ''
"
/>
</div>
</template>
@@ -0,0 +1,126 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const searchQuery = ref('');
const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
});
const filteredTemplates = computed(() => {
return whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const getTemplateBody = template => {
return template.components.find(component => component.type === 'BODY').text;
};
const handleTriggerClick = () => {
searchQuery.value = '';
showTemplatesMenu.value = !showTemplatesMenu.value;
};
const handleTemplateClick = template => {
selectedTemplate.value = template;
showTemplatesMenu.value = false;
};
const handleBack = () => {
selectedTemplate.value = null;
showTemplatesMenu.value = true;
};
const handleSendMessage = template => {
emit('sendMessage', template);
selectedTemplate.value = null;
};
</script>
<template>
<div class="relative">
<Button
icon="i-ri-whatsapp-line"
:label="t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.LABEL')"
color="slate"
size="sm"
:disabled="selectedTemplate"
class="!text-xs font-medium"
@click="handleTriggerClick"
/>
<div
v-if="showTemplatesMenu"
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[350px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<div class="relative w-full">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
v-model="searchQuery"
type="search"
:placeholder="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
)
"
class="w-full h-8 py-2 pl-10 pr-2 text-sm border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
{{ getTemplateBody(template) }}
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@back="handleBack"
/>
</div>
</template>
@@ -0,0 +1,176 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
template: {
type: Object,
required: true,
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const templateName = computed(() => {
return props.template?.name || '';
});
const templateString = computed(() => {
return props.template?.components?.find(
component => component.type === 'BODY'
).text;
});
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const processedStringWithVariableHighlight = computed(() => {
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
return variables.reduce((result, variable) => {
const variableKey = processVariable(variable);
const value = processedParams.value[variableKey] || variable;
return result.replace(
variable,
`<span class="break-all text-n-slate-12">${value}</span>`
);
}, templateString.value);
});
const rules = computed(() => {
const paramRules = {};
Object.keys(processedParams.value).forEach(key => {
paramRules[key] = { required: requiredIf(true) };
});
return {
processedParams: paramRules,
};
});
const v$ = useVuelidate(rules, { processedParams });
const getFieldErrorType = key => {
if (!v$.value.processedParams[key]?.$error) return 'info';
return 'error';
};
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) return;
const finalVars = matchedVariables.map(i => processVariable(i));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = '';
return acc;
}, {});
};
const sendMessage = async () => {
const isValid = await v$.value.$validate();
if (!isValid) return;
const payload = {
message: processedString.value,
templateParams: {
name: props.template.name,
category: props.template.category,
language: props.template.language,
namespace: props.template.namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
onMounted(() => {
generateVariables();
});
</script>
<template>
<div
class="absolute top-full mt-1.5 max-h-[500px] overflow-y-auto left-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[460px] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
>
<span class="text-sm text-n-slate-12">
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
{ templateName: templateName }
)
}}
</span>
<p
class="mb-0 text-sm text-n-slate-11"
v-html="processedStringWithVariableHighlight"
/>
<span
v-if="Object.keys(processedParams).length"
class="text-sm font-medium text-n-slate-12"
>
{{
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
)
}}
</span>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="flex items-center w-full gap-2"
>
<span
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
>
{{ key }}
</span>
<Input
v-model="processedParams[key]"
custom-input-class="!h-8 w-full !bg-transparent"
class="w-full"
:message-type="getFieldErrorType(key)"
/>
</div>
<div class="flex items-end justify-between w-full gap-3 h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="emit('back')"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
@click="sendMessage"
/>
</div>
</div>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { ref } from 'vue';
import { contacts, activeContact, emailInbox, currentUser } from './fixtures';
import ComposeNewConversationForm from '../ComposeNewConversationForm.vue';
const selectedContact = ref(activeContact);
const targetInbox = ref(emailInbox);
// Event handlers
const onSearchContacts = query => {
console.log('Searching contacts:', query);
};
const onUpdateSelectedContact = contact => {
console.log('Selected contact updated:', contact);
};
const onUpdateTargetInbox = inbox => {
console.log('Target inbox updated:', inbox);
targetInbox.value = inbox;
console.log('Target inbox updated:', inbox);
};
const onClearSelectedContact = () => {
console.log('Contact cleared');
};
const onCreateConversation = payload => {
console.log('Creating conversation:', payload);
};
const onDiscard = () => {
console.log('Form discarded');
};
</script>
<template>
<Story
title="Components/Compose/ComposeNewConversationForm"
:layout="{ type: 'grid', width: '800px' }"
>
<Variant title="With all props">
<div class="h-[600px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="selectedContact"
:target-inbox="targetInbox"
:is-creating-contact="false"
is-fetching-inboxes
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
<Variant title="With no target inbox">
<div class="h-[200px] w-full relative">
<ComposeNewConversationForm
:contacts="contacts"
contact-id=""
:is-loading="false"
:current-user="currentUser"
:selected-contact="{ ...selectedContact, contactInboxes: [] }"
:target-inbox="null"
:is-creating-contact="false"
:is-fetching-inboxes="false"
is-direct-uploads-enabled
:contact-conversations-ui-flags="{ isCreating: false }"
:contacts-ui-flags="{ isFetching: false }"
class="!top-0"
@search-contacts="onSearchContacts"
@update-selected-contact="onUpdateSelectedContact"
@update-target-inbox="onUpdateTargetInbox"
@clear-selected-contact="onClearSelectedContact"
@create-conversation="onCreateConversation"
@discard="onDiscard"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,168 @@
export const contacts = [
{
additionalAttributes: {
city: 'kerala',
country: 'India',
description: 'Curious about the web. ',
companyName: 'Chatwoot',
countryCode: '',
socialProfiles: {
github: 'abozler',
twitter: 'ozler',
facebook: 'abozler',
linkedin: 'abozler',
instagram: 'ozler',
},
},
availabilityStatus: 'offline',
email: 'ozler@chatwoot.com',
id: 29,
name: 'Abraham Ozlers',
phoneNumber: '+246232222222',
identifier: null,
thumbnail:
'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
customAttributes: {
dateContact: '2024-02-01T00:00:00.000Z',
linkContact: 'https://staging.chatwoot.com/app/accounts/3/contacts-new',
listContact: 'Not spam',
numberContact: '12',
},
lastActivityAt: 1712127410,
createdAt: 1712127389,
},
];
export const activeContact = {
email: 'ozler@chatwoot.com',
id: 29,
label: 'Abraham Ozlers (ozler@chatwoot.com)',
name: 'Abraham Ozlers',
thumbnail: {
name: 'Abraham Ozlers',
src: 'https://sivin-tunnel.chatwoot.dev/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBBc0FCIiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--c20b627b384f5981112e949b8414cd4d3e5912ee/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2RTNKbGMybDZaVjkwYjE5bWFXeHNXd2RwQWZvdyIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--ebe60765d222d11ade39165eae49cc4b2de18d89/Avatar%201.20.41%E2%80%AFAM.png',
},
contactInboxes: [
{
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
},
{
id: 8,
label: 'PaperLayer WhatsApp',
name: 'PaperLayer WhatsApp',
sourceId: '123456',
phoneNumber: '+1223233434',
channelType: 'Channel::Whatsapp',
messageTemplates: [
{
id: '1',
name: 'shipment_confirmation',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: '{{1}}, great news! Your order {{2}} has shipped.\n\nTracking #: {{3}}\nEstimated delivery: {{4}}\n\nWe will provide updates until delivery.',
type: 'BODY',
example: {
bodyText: [['John', '#12345', 'ZK4539O2311J', 'Jan 1, 2024']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/',
text: 'Track shipment',
type: 'URL',
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'shipment_confirmation_2',
},
{
id: '2',
name: 'otp_test',
status: 'APPROVED',
category: 'AUTHENTICATION',
language: 'en_US',
components: [
{
text: 'Use code *{{1}}* to authorize your transaction.',
type: 'BODY',
example: {
bodyText: [['123456']],
},
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp{{1}}',
text: 'Copy code',
type: 'URL',
example: [
'https://www.example.com/otp/code/?otp_type=ZERO_TAP&cta_display_name=Autofill&package_name=com.app&signature_hash=weew&code=otp123456',
],
},
],
},
],
parameterFormat: 'POSITIONAL',
libraryTemplateName: 'verify_transaction_1',
messageSendTtlSeconds: 900,
},
{
id: '3',
name: 'hello_world',
status: 'APPROVED',
category: 'UTILITY',
language: 'en_US',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
parameterFormat: 'POSITIONAL',
},
],
},
{
id: 9,
label: 'PaperLayer API',
name: 'PaperLayer API',
email: '',
channelType: 'Channel::Api',
},
],
};
export const emailInbox = {
id: 7,
label: 'PaperLayer Email (testba@paperlayer.test)',
name: 'PaperLayer Email',
email: 'testba@paperlayer.test',
channelType: 'Channel::Email',
};
export const currentUser = {
id: 1,
name: 'John Doe',
email: 'john@example.com',
};
@@ -0,0 +1,219 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
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,
channelType,
phoneNumber,
}) => {
if (channelType === INBOX_TYPES.EMAIL) {
return `${name} (${email})`;
}
if (
channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP
) {
return `${name} (${phoneNumber})`;
}
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(transformInbox).sort(compareInboxes);
};
export const getCapitalizedNameFromEmail = email => {
const name = email.match(/^([^@]*)@/)?.[1] || email.split('@')[0];
return name.charAt(0).toUpperCase() + name.slice(1);
};
export const processContactableInboxes = inboxes => {
return inboxes.map(inbox => ({
...inbox.inbox,
sourceId: inbox.sourceId,
}));
};
export const prepareAttachmentPayload = (
attachedFiles,
directUploadsEnabled
) => {
const files = [];
attachedFiles.forEach(attachment => {
if (directUploadsEnabled) {
files.push(attachment.blobSignedId);
} else {
files.push(attachment.resource.file);
}
});
return files;
};
export const prepareNewMessagePayload = ({
targetInbox,
selectedContact,
message,
subject,
ccEmails,
bccEmails,
currentUser,
attachedFiles = [],
directUploadsEnabled = false,
}) => {
const payload = {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: Number(selectedContact.id),
message: { content: message },
assigneeId: currentUser.id,
};
if (attachedFiles?.length) {
payload.files = prepareAttachmentPayload(
attachedFiles,
directUploadsEnabled
);
}
if (subject) {
payload.mailSubject = subject;
}
if (ccEmails) {
payload.message.cc_emails = ccEmails;
}
if (bccEmails) {
payload.message.bcc_emails = bccEmails;
}
return payload;
};
export const prepareWhatsAppMessagePayload = ({
targetInbox,
selectedContact,
message,
templateParams,
currentUser,
}) => {
return {
inboxId: targetInbox.id,
sourceId: targetInbox.sourceId,
contactId: selectedContact.id,
message: { content: message, template_params: templateParams },
assigneeId: currentUser.id,
};
};
export const generateContactQuery = ({ keys = ['email'], query }) => {
return {
payload: keys.map(key => {
const filterPayload = {
attribute_key: key,
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
};
if (keys.findIndex(k => k === key) !== keys.length - 1) {
filterPayload.query_operator = 'or';
}
return filterPayload;
}),
};
};
// API Calls
export const searchContacts = async ({ keys, query }) => {
const {
data: { payload },
} = await ContactAPI.filter(
undefined,
'name',
generateContactQuery({ keys, query })
);
const camelCasedPayload = camelcaseKeys(payload, { deep: true });
// Filter contacts that have either phone_number or email
const filteredPayload = camelCasedPayload?.filter(
contact => contact.phoneNumber || contact.email
);
return filteredPayload || [];
};
export const createNewContact = async email => {
const payload = {
name: getCapitalizedNameFromEmail(email),
email,
};
const {
data: {
payload: { contact: newContact },
},
} = await ContactAPI.create(payload);
return camelcaseKeys(newContact, { deep: true });
};
export const fetchContactableInboxes = async contactId => {
const {
data: { payload: inboxes = [] },
} = await ContactAPI.getContactableInboxes(contactId);
const convertInboxesToCamelKeys = camelcaseKeys(inboxes, { deep: true });
return processContactableInboxes(convertInboxesToCamelKeys);
};
@@ -0,0 +1,536 @@
import { describe, it, expect, vi } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import ContactAPI from 'dashboard/api/contacts';
import * as helpers from '../composeConversationHelper';
vi.mock('dashboard/api/contacts');
describe('composeConversationHelper', () => {
describe('generateLabelForContactableInboxesList', () => {
const contact = {
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
};
it('generates label for email inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.EMAIL,
})
).toBe('John Doe (john@example.com)');
});
it('generates label for twilio inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.TWILIO,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for whatsapp inbox', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: INBOX_TYPES.WHATSAPP,
})
).toBe('John Doe (+1234567890)');
});
it('generates label for other inbox types', () => {
expect(
helpers.generateLabelForContactableInboxesList({
...contact,
channelType: 'Channel::Api',
})
).toBe('John Doe');
});
});
describe('buildContactableInboxesList', () => {
it('returns empty array if no contact inboxes', () => {
expect(helpers.buildContactableInboxesList(null)).toEqual([]);
expect(helpers.buildContactableInboxesList(undefined)).toEqual([]);
});
it('builds list of contactable inboxes with correct format', () => {
const inboxes = [
{
id: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
phoneNumber: null,
},
];
const result = helpers.buildContactableInboxesList(inboxes);
expect(result[0]).toMatchObject({
id: 1,
icon: 'i-ri-mail-line',
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
describe('getCapitalizedNameFromEmail', () => {
it('extracts and capitalizes name from email', () => {
expect(helpers.getCapitalizedNameFromEmail('john.doe@example.com')).toBe(
'John.doe'
);
expect(helpers.getCapitalizedNameFromEmail('jane@example.com')).toBe(
'Jane'
);
});
});
describe('processContactableInboxes', () => {
it('processes inboxes with correct structure', () => {
const inboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
const result = helpers.processContactableInboxes(inboxes);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
});
});
describe('prepareAttachmentPayload', () => {
it('prepares direct upload files', () => {
const files = [{ blobSignedId: 'signed1' }];
expect(helpers.prepareAttachmentPayload(files, true)).toEqual([
'signed1',
]);
});
it('prepares regular files', () => {
const files = [{ resource: { file: 'file1' } }];
expect(helpers.prepareAttachmentPayload(files, false)).toEqual(['file1']);
});
});
describe('prepareNewMessagePayload', () => {
const baseParams = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: '2' },
message: 'Hello',
currentUser: { id: 3 },
};
it('prepares basic message payload', () => {
const result = helpers.prepareNewMessagePayload(baseParams);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: { content: 'Hello' },
assigneeId: 3,
});
});
it('includes optional fields when provided', () => {
const result = helpers.prepareNewMessagePayload({
...baseParams,
subject: 'Test',
ccEmails: 'cc@test.com',
bccEmails: 'bcc@test.com',
attachedFiles: [{ blobSignedId: 'file1' }],
directUploadsEnabled: true,
});
expect(result).toMatchObject({
mailSubject: 'Test',
message: {
content: 'Hello',
cc_emails: 'cc@test.com',
bcc_emails: 'bcc@test.com',
},
files: ['file1'],
});
});
});
describe('prepareWhatsAppMessagePayload', () => {
it('prepares whatsapp message payload', () => {
const params = {
targetInbox: { id: 1, sourceId: 'source1' },
selectedContact: { id: 2 },
message: 'Hello',
templateParams: { param1: 'value1' },
currentUser: { id: 3 },
};
const result = helpers.prepareWhatsAppMessagePayload(params);
expect(result).toEqual({
inboxId: 1,
sourceId: 'source1',
contactId: 2,
message: {
content: 'Hello',
template_params: { param1: 'value1' },
},
assigneeId: 3,
});
});
});
describe('generateContactQuery', () => {
it('generates correct query structure for contact search', () => {
const query = 'test@example.com';
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [query],
attribute_model: 'standard',
},
],
};
expect(helpers.generateContactQuery({ keys: ['email'], query })).toEqual(
expected
);
});
it('handles empty query', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: [''],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({ keys: ['email'], query: '' })
).toEqual(expected);
});
it('handles mutliple keys', () => {
const expected = {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
query_operator: 'or',
},
{
attribute_key: 'phone_number',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
};
expect(
helpers.generateContactQuery({
keys: ['email', 'phone_number'],
query: 'john',
})
).toEqual(expected);
});
});
describe('API calls', () => {
describe('searchContacts', () => {
it('searches contacts and returns camelCase results', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('searches contacts and returns only contacts with email or phone number', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phone_number: '+1234567890',
created_at: '2023-01-01',
},
{
id: 2,
name: 'Jane Doe',
email: null,
phone_number: null,
created_at: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phone_number: null,
created_at: '2023-01-01',
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts({
keys: ['email'],
query: 'john',
});
// Should only return contacts with either email or phone number
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
email: 'john@example.com',
phoneNumber: '+1234567890',
createdAt: '2023-01-01',
},
{
id: 3,
name: 'Bob Smith',
email: 'bob@example.com',
phoneNumber: null,
createdAt: '2023-01-01',
},
]);
expect(ContactAPI.filter).toHaveBeenCalledWith(undefined, 'name', {
payload: [
{
attribute_key: 'email',
filter_operator: 'contains',
values: ['john'],
attribute_model: 'standard',
},
],
});
});
it('handles empty search results', async () => {
ContactAPI.filter.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.searchContacts('nonexistent');
expect(result).toEqual([]);
});
it('transforms nested objects to camelCase', async () => {
const mockPayload = [
{
id: 1,
name: 'John Doe',
phone_number: '+1234567890',
contact_inboxes: [
{
inbox_id: 1,
source_id: 'source1',
created_at: '2023-01-01',
},
],
custom_attributes: {
custom_field_name: 'value',
},
},
];
ContactAPI.filter.mockResolvedValue({
data: { payload: mockPayload },
});
const result = await helpers.searchContacts('test');
expect(result).toEqual([
{
id: 1,
name: 'John Doe',
phoneNumber: '+1234567890',
contactInboxes: [
{
inboxId: 1,
sourceId: 'source1',
createdAt: '2023-01-01',
},
],
customAttributes: {
customFieldName: 'value',
},
},
]);
});
});
describe('createNewContact', () => {
it('creates new contact with capitalized name', async () => {
const mockContact = { id: 1, name: 'John', email: 'john@example.com' };
ContactAPI.create.mockResolvedValue({
data: { payload: { contact: mockContact } },
});
const result = await helpers.createNewContact('john@example.com');
expect(result).toEqual(mockContact);
expect(ContactAPI.create).toHaveBeenCalledWith({
name: 'John',
email: 'john@example.com',
});
});
});
describe('fetchContactableInboxes', () => {
it('fetches and processes contactable inboxes', async () => {
const mockInboxes = [
{
inbox: { id: 1, name: 'Inbox 1' },
sourceId: 'source1',
},
];
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: mockInboxes },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result[0]).toEqual({
id: 1,
name: 'Inbox 1',
sourceId: 'source1',
});
expect(ContactAPI.getContactableInboxes).toHaveBeenCalledWith(1);
});
it('returns empty array when no inboxes found', async () => {
ContactAPI.getContactableInboxes.mockResolvedValue({
data: { payload: [] },
});
const result = await helpers.fetchContactableInboxes(1);
expect(result).toEqual([]);
});
});
});
});
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>
@@ -25,6 +25,10 @@ const props = defineProps({
type: String,
default: '',
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
@@ -71,8 +75,8 @@ onMounted(() => {
/>
</div>
<button
v-for="item in filteredMenuItems"
:key="item.action"
v-for="(item, index) in filteredMenuItems"
:key="index"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
@@ -81,7 +85,6 @@ onMounted(() => {
}"
:disabled="item.disabled"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
@@ -92,7 +95,7 @@ onMounted(() => {
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0" />
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span v-if="item.label" class="min-w-0 text-sm truncate">{{
item.label
@@ -102,7 +105,11 @@ onMounted(() => {
v-if="filteredMenuItems.length === 0"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{ t('DROPDOWN_MENU.EMPTY_STATE') }}
{{
isSearching
? t('DROPDOWN_MENU.SEARCHING')
: t('DROPDOWN_MENU.EMPTY_STATE')
}}
</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>
@@ -4,22 +4,11 @@ import { replaceUnderscoreWithSpace } from './helper/filterHelper.js';
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
appliedFilters: {
type: Array,
default: () => [],
},
maxVisibleFilters: {
type: Number,
default: 2,
},
clearButtonLabel: {
type: String,
default: '',
},
moreFiltersLabel: {
type: String,
default: '',
},
appliedFilters: { type: Array, default: () => [] },
maxVisibleFilters: { type: Number, default: 2 },
clearButtonLabel: { type: String, default: '' },
moreFiltersLabel: { type: String, default: '' },
showClearButton: { type: Boolean, default: true },
});
const emit = defineEmits(['clearFilters', 'openFilter']);
@@ -46,6 +35,9 @@ const formatOperatorLabel = operator => {
const formatFilterValue = value => {
if (!value) return '';
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object' && value.name) {
return value.name;
}
@@ -55,10 +47,7 @@ const formatFilterValue = value => {
<template>
<div class="flex flex-wrap items-center w-full gap-2 mx-auto">
<template
v-for="(filter, index) in appliedFilters"
:key="filter.attributeKey"
>
<template v-for="(filter, index) in appliedFilters" :key="index">
<div
v-if="index < maxVisibleFilters"
class="inline-flex items-center gap-2 h-7"
@@ -107,8 +96,9 @@ const formatFilterValue = value => {
>
{{ moreFiltersLabel }}
</div>
<div class="w-px h-3 rounded-lg bg-n-strong" />
<div v-if="showClearButton" class="w-px h-3 rounded-lg bg-n-strong" />
<Button
v-if="showClearButton"
:label="clearButtonLabel"
size="xs"
class="!px-1"
@@ -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,9 +1,7 @@
<script setup>
defineProps({
modelValue: {
type: [String, Number],
default: '',
},
import { ref, onMounted, nextTick } from 'vue';
const props = defineProps({
type: {
type: String,
default: 'text',
@@ -32,13 +30,49 @@ defineProps({
type: Boolean,
default: false,
},
focusOnMount: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue', 'enterPress']);
const emit = defineEmits(['enterPress', 'input', 'blur', 'focus']);
const modelValue = defineModel({
type: [String, Number],
default: '',
});
const inlineInputRef = ref(null);
const onEnterPress = () => {
emit('enterPress');
};
const handleInput = event => {
emit('input', event.target.value);
modelValue.value = event.target.value;
};
const handleBlur = event => {
emit('blur', event.target.value);
};
const handleFocus = event => {
emit('focus', event.target.value);
};
onMounted(() => {
nextTick(() => {
if (props.focusOnMount) {
inlineInputRef.value?.focus();
}
});
});
defineExpose({
focus: () => inlineInputRef.value?.focus(),
});
</script>
<template>
@@ -49,7 +83,7 @@ const onEnterPress = () => {
v-if="label"
:for="id"
:class="customLabelClass"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
class="mb-0.5 text-sm font-medium text-n-slate-11"
>
{{ label }}
</label>
@@ -57,13 +91,16 @@ const onEnterPress = () => {
<slot name="prefix" />
<input
:id="id"
:value="modelValue"
ref="inlineInputRef"
v-model="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="customInputClass"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-lg bg-transparent dark:bg-transparent placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
@input="$emit('update:modelValue', $event.target.value)"
class="flex w-full reset-base text-sm h-6 !mb-0 border-0 rounded-none bg-transparent dark:bg-transparent placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 dark:text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@keydown.enter.prevent="onEnterPress"
/>
</div>
@@ -7,17 +7,17 @@ import { useMapGetter } from 'dashboard/composables/store';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { useStorage } from '@vueuse/core';
import { useSidebarKeyboardShortcuts } from './useSidebarKeyboardShortcuts';
import Button from 'dashboard/components-next/button/Button.vue';
import SidebarGroup from './SidebarGroup.vue';
import SidebarProfileMenu from './SidebarProfileMenu.vue';
import ChannelLeaf from './ChannelLeaf.vue';
import SidebarNotificationBell from './SidebarNotificationBell.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
const emit = defineEmits([
'openNotificationPanel',
'closeKeyShortcutModal',
'openKeyShortcutModal',
'showCreateAccountModal',
@@ -27,7 +27,6 @@ const { accountScopedRoute } = useAccount();
const store = useStore();
const searchShortcut = useKbd([`$mod`, 'k']);
const { t } = useI18n();
const enableNewConversation = false;
const toggleShortcutModalFn = show => {
if (show) {
@@ -481,7 +480,7 @@ const menuItems = computed(() => {
<div class="flex gap-2 px-2">
<RouterLink
:to="{ name: 'search' }"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3 dark:bg-n-black/30"
class="flex items-center w-full gap-2 px-2 py-1 rounded-lg h-7 outline outline-1 outline-n-weak bg-n-solid-3 dark:bg-n-black/30"
>
<span class="flex-shrink-0 i-lucide-search size-4 text-n-slate-11" />
<span class="flex-grow text-left">
@@ -493,14 +492,17 @@ const menuItems = computed(() => {
{{ searchShortcut }}
</span>
</RouterLink>
<button
v-if="enableNewConversation"
class="flex items-center w-full gap-2 px-2 py-1 border rounded-lg border-n-weak bg-n-solid-3"
>
<span
class="flex-shrink-0 i-lucide-square-pen size-4 text-n-slate-11"
/>
</button>
<ComposeConversation align-position="right">
<template #trigger="{ toggle }">
<Button
icon="i-lucide-pen-line"
color="slate"
size="sm"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="toggle"
/>
</template>
</ComposeConversation>
</div>
</section>
<nav class="grid flex-grow gap-2 px-2 pb-5 overflow-y-scroll no-scrollbar">
@@ -518,12 +520,6 @@ const menuItems = computed(() => {
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"
/>
<div v-if="false" class="flex items-center">
<div class="flex-shrink-0 w-px h-3 bg-n-strong" />
<SidebarNotificationBell
@open-notification-panel="emit('openNotificationPanel')"
/>
</div>
</section>
</aside>
</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',
},
{
@@ -120,7 +117,7 @@ const allowedMenuItems = computed(() => {
</div>
</button>
</template>
<DropdownBody class="left-0 bottom-12 z-50 w-80 mb-1">
<DropdownBody class="ltr:left-0 rtl:right-0 bottom-12 z-50 w-80 mb-2">
<SidebarProfileMenuStatus />
<DropdownSeparator />
<template v-for="item in allowedMenuItems" :key="item.label">
@@ -1,51 +1,170 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { vOnClickOutside } from '@vueuse/components';
import { email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const props = defineProps({
modelValue: {
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
type: { type: String, default: 'text' },
isLoading: { type: Boolean, default: false },
menuItems: {
type: Array,
default: () => [],
validator: value =>
value.every(
({ action, value: tagValue, label }) => action && tagValue && label
),
},
placeholder: {
showDropdown: { type: Boolean, default: false },
mode: {
type: String,
default: '',
default: 'multiple',
validator: value => ['single', 'multiple'].includes(value),
},
focusOnMount: { type: Boolean, default: false },
allowCreate: { type: Boolean, default: false },
});
const emit = defineEmits(['update:modelValue']);
const emit = defineEmits([
'update:modelValue',
'input',
'blur',
'focus',
'onClickOutside',
'add',
'remove',
]);
const modelValue = defineModel({
type: Array,
default: () => [],
});
const MODE = {
SINGLE: 'single',
MULTIPLE: 'multiple',
};
const tagInputRef = ref(null);
const tags = ref(props.modelValue);
const newTag = ref('');
const isFocused = ref(false);
const isFocused = ref(true);
const showInput = computed(() => isFocused.value || tags.value.length === 0);
const rules = computed(() => ({
newTag: props.type === 'email' ? { email } : {},
}));
const addTag = () => {
if (newTag.value.trim()) {
tags.value.push(newTag.value.trim());
newTag.value = '';
emit('update:modelValue', tags.value);
const v$ = useVuelidate(rules, { newTag });
const isNewTagInValidType = computed(() => v$.value.$invalid);
const showInput = computed(() =>
props.mode === MODE.SINGLE
? isFocused.value && !tags.value.length
: isFocused.value || !tags.value.length
);
const showDropdownMenu = computed(() =>
props.mode === MODE.SINGLE && tags.value.length >= 1
? false
: props.showDropdown
);
const filteredMenuItems = computed(() => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return [];
const availableMenuItems = props.menuItems.filter(
item => !tags.value.includes(item.label)
);
// Show typed value as suggestion only if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. Email validation passes (if type is email) and There are no menu items available
const trimmedNewTag = newTag.value?.trim();
const shouldShowTypedValue =
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!props.isLoading &&
!availableMenuItems.length &&
(props.type === 'email' ? !isNewTagInValidType.value : true);
if (shouldShowTypedValue) {
return [
{
label: trimmedNewTag,
value: trimmedNewTag,
email: trimmedNewTag,
thumbnail: { name: trimmedNewTag, src: '' },
action: 'create',
},
];
}
return availableMenuItems;
});
const emitDataOnAdd = emailValue => {
const matchingMenuItem = props.menuItems.find(
item => item.email === emailValue
);
return matchingMenuItem
? emit('add', { email: emailValue, ...matchingMenuItem })
: emit('add', { value: emailValue, action: 'create' });
};
const addTag = async () => {
const trimmedTag = newTag.value?.trim();
if (!trimmedTag) return;
if (props.mode === MODE.SINGLE && tags.value.length >= 1) {
newTag.value = '';
return;
}
if (props.type === 'email' || props.allowCreate) {
if (!(await v$.value.$validate())) return;
emitDataOnAdd(trimmedTag);
}
tags.value.push(trimmedTag);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const removeTag = index => {
tags.value.splice(index, 1);
emit('update:modelValue', tags.value);
modelValue.value = tags.value;
emit('remove');
};
const handleDropdownAction = async ({ email: emailAddress, ...rest }) => {
if (props.mode === MODE.SINGLE && tags.value.length >= 1) return;
if (props.type === 'email' && props.showDropdown) {
newTag.value = emailAddress;
if (!(await v$.value.$validate())) return;
emit('add', { email: emailAddress, ...rest });
}
tags.value.push(emailAddress);
newTag.value = '';
modelValue.value = tags.value;
tagInputRef.value?.focus();
};
const handleFocus = () => {
emit('focus');
tagInputRef.value?.focus();
isFocused.value = true;
};
const handleClickOutside = () => {
if (tags.value.length > 0) {
isFocused.value = false;
}
};
const handleKeydown = event => {
if (event.key === ',') {
event.preventDefault();
@@ -53,43 +172,78 @@ const handleKeydown = event => {
}
};
const handleClickOutside = () => {
if (tags.value.length) isFocused.value = false;
emit('onClickOutside');
};
watch(
() => props.modelValue,
newValue => {
tags.value = newValue;
}
);
watch(
() => newTag.value,
async newValue => {
if (props.type === 'email' && newValue?.trim()?.length > 2) {
await v$.value.$validate();
}
}
);
const handleInput = e => emit('input', e);
const handleBlur = e => emit('blur', e);
</script>
<template>
<OnClickOutside @trigger="handleClickOutside">
<div
v-on-click-outside="() => handleClickOutside()"
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
>
<div
class="flex flex-wrap w-full gap-2 border border-transparent focus:outline-none"
tabindex="0"
@focus="handleFocus"
@click="handleFocus"
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<div
v-for="(tag, index) in tags"
:key="index"
class="flex items-center justify-center max-w-full gap-1 px-3 py-1 rounded-lg h-7 bg-n-alpha-2"
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
<InlineInput
v-if="showInput"
v-model="newTag"
:placeholder="placeholder"
custom-input-class="flex-grow"
@enter-press="addTag"
@keydown="handleKeydown"
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">{{
tag
}}</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3.5 text-n-slate-11"
@click.stop="removeTag(index)"
/>
</div>
</OnClickOutside>
<div
v-if="showInput || showDropdownMenu"
class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full"
>
<InlineInput
ref="tagInputRef"
v-model="newTag"
:placeholder="placeholder"
:type="type"
:disabled="disabled"
class="w-full"
:focus-on-mount="focusOnMount"
:custom-input-class="`w-full ${isNewTagInValidType ? '!text-n-ruby-9 dark:!text-n-ruby-9' : ''}`"
@enter-press="addTag"
@focus="handleFocus"
@input="handleInput"
@blur="handleBlur"
@keydown="handleKeydown"
/>
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="filteredMenuItems"
:is-searching="isLoading"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
@action="handleDropdownAction"
/>
</div>
</div>
</template>
@@ -179,7 +179,7 @@ onMounted(() => {
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-10 dark:placeholder:text-n-slate-10 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -782,10 +782,10 @@ watch(conversationFilters, (newVal, oldVal) => {
<template>
<div
class="flex flex-col flex-shrink-0 border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
class="flex flex-col flex-shrink-0 conversations-list-wrap"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
isOnExpandedLayout ? 'basis-full' : 'w-[360px] 2xl:w-[420px]',
]"
>
<slot />
@@ -916,13 +916,3 @@ watch(conversationFilters, (newVal, oldVal) => {
</Teleport>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
.flex-basis-clamp {
flex-basis: clamp(20rem, 4vw + 21.25rem, 27.5rem);
}
}
</style>
@@ -0,0 +1,58 @@
<script setup>
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import IntegrationsAPI from 'dashboard/api/integrations';
import { useMapGetter } from 'dashboard/composables/store';
import { ref } from 'vue';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const currentUser = useMapGetter('getCurrentUser');
const messages = ref([]);
const isCaptainTyping = ref(false);
const sendMessage = async message => {
// Add user message
messages.value.push({
id: messages.value.length + 1,
role: 'user',
content: message,
});
isCaptainTyping.value = true;
try {
const { data } = await IntegrationsAPI.requestCaptainCopilot({
previous_history: messages.value
.map(m => ({
role: m.role,
content: m.content,
}))
.slice(0, -1),
message,
conversation_id: props.conversationId,
});
messages.value.push({
id: new Date().getTime(),
role: 'assistant',
content: data.message,
});
} catch (error) {
// eslint-disable-next-line
console.log(error);
} finally {
isCaptainTyping.value = false;
}
};
</script>
<template>
<Copilot
:messages="messages"
:support-agent="currentUser"
:is-captain-typing="isCaptainTyping"
@send-message="sendMessage"
/>
</template>
@@ -3,18 +3,20 @@ import { frontendURL } from '../../../../helper/URLHelper';
const contacts = accountId => ({
parentNav: 'contacts',
routes: [
'contacts_dashboard',
'contacts_dashboard_index',
'contacts_dashboard_segments_index',
'contacts_dashboard_labels_index',
'contacts_edit',
'contacts_segments_dashboard',
'contacts_labels_dashboard',
'contacts_edit_segment',
'contacts_edit_label',
],
menuItems: [
{
icon: 'contact-card-group',
label: 'ALL_CONTACTS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toState: frontendURL(`accounts/${accountId}/contacts?page=1`),
toStateName: 'contacts_dashboard_index',
},
],
});
@@ -31,7 +31,7 @@ const primaryMenuItems = accountId => [
label: 'CONTACTS',
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
toStateName: 'contacts_dashboard_index',
},
{
icon: 'arrow-trending-lines',
@@ -134,7 +134,7 @@ export default {
icon: 'number-symbol',
label: 'TAGGED_WITH',
hasSubMenu: true,
key: 'label',
key: 'labels',
newLink: this.showNewLink(FEATURE_FLAGS.TEAM_MANAGEMENT),
newLinkTag: 'NEW_LABEL',
toState: frontendURL(`accounts/${this.accountId}/settings/labels`),
@@ -147,7 +147,7 @@ export default {
color: label.color,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/labels/${label.title}/contacts`
`accounts/${this.accountId}/contacts/labels/${label.title}`
),
})),
};
@@ -194,7 +194,7 @@ export default {
icon: 'folder',
label: 'CUSTOM_VIEWS_SEGMENTS',
hasSubMenu: true,
key: 'custom_view',
key: 'segments',
children: this.customViews
.filter(view => view.filter_type === 'contact')
.map(view => ({
@@ -202,7 +202,7 @@ export default {
label: view.name,
truncateLabel: true,
toState: frontendURL(
`accounts/${this.accountId}/contacts/custom_view/${view.id}`
`accounts/${this.accountId}/contacts/segments/${view.id}`
),
})),
};
@@ -247,7 +247,7 @@ export default {
<transition-group
name="menu-list"
tag="ul"
class="pt-2 reset-base list-none"
class="pt-2 list-none reset-base"
>
<SecondaryNavItem
v-for="menuItem in accessibleMenuItems"
@@ -119,6 +119,13 @@ export default {
this.menuItem.toStateName === 'settings_applications'
);
},
isContactsDefaultRoute() {
return (
this.menuItem.toStateName === 'contacts_dashboard_index' &&
(this.$store.state.route.name === 'contacts_dashboard_index' ||
this.$store.state.route.name === 'contacts_edit')
);
},
isCurrentRoute() {
return this.$store.state.route.name.includes(this.menuItem.toStateName);
},
@@ -130,6 +137,7 @@ export default {
this.isAllConversations ||
this.isMentions ||
this.isUnattended ||
this.isContactsDefaultRoute ||
this.isCurrentRoute
) {
return 'bg-woot-25 dark:bg-slate-800 text-woot-500 dark:text-woot-500 hover:text-woot-500 dark:hover:text-woot-500 active-view';
@@ -242,7 +250,7 @@ export default {
</span>
</router-link>
<ul v-if="hasSubMenu" class="reset-base list-none">
<ul v-if="hasSubMenu" class="list-none reset-base">
<SecondaryChildNavItem
v-for="child in menuItem.children"
:key="child.id"
@@ -1,407 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import FilterInputBox from '../FilterInput/Index.vue';
import languages from './advancedFilterItems/languages';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
import { useTrack } from 'dashboard/composables';
export default {
components: {
FilterInputBox,
},
props: {
onClose: {
type: Function,
default: () => {},
},
initialFilterTypes: {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
activeFolderName: {
type: String,
default: '',
},
isFolderView: {
type: Boolean,
default: false,
},
},
emits: ['applyFilter', 'updateFolder'],
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeFolderNewName: this.activeFolderName,
filterTypes: this.initialFilterTypes,
filterAttributeGroups,
filterGroups: [],
allCustomAttributes: [],
attributeModel: 'conversation_attribute',
filtersFori18n: 'FILTER',
validationErrors: {},
};
},
computed: {
...mapGetters({
getAppliedConversationFilters: 'getAppliedConversationFilters',
}),
filterModalHeaderTitle() {
return !this.isFolderView
? this.$t('FILTER.TITLE')
: this.$t('FILTER.EDIT_CUSTOM_FILTER');
},
filterModalSubTitle() {
return !this.isFolderView
? this.$t('FILTER.SUBTITLE')
: this.$t('FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
this.appliedFilters = [...this.getAppliedConversationFilters];
} else if (!this.isFolderView) {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
attribute_model: 'standard',
});
}
},
methods: {
getOperatorTypes(key) {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
},
customAttributeInputType(key) {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
},
getAttributeModel(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key, operator) {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.filterOperators;
},
statusFilterItems() {
return {
open: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
},
resolved: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
},
pending: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
},
snoozed: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
},
all: {
TEXT: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
},
};
},
getDropdownValues(type) {
const statusFilters = this.statusFilterItems();
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type &&
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox) {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
}
const isCustomAttributeList = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
if (isCustomAttributeList) {
return allCustomAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
}
switch (type) {
case 'status':
return [
...Object.keys(statusFilters).map(status => {
return {
id: status,
name: statusFilters[status].TEXT,
};
}),
];
case 'assignee_id':
return this.$store.getters['agents/getAgents'];
case 'contact':
return this.$store.getters['contacts/getContacts'];
case 'inbox_id':
return this.$store.getters['inboxes/getInboxes'];
case 'team_id':
return this.$store.getters['teams/getTeams'];
case 'campaign_id':
return this.$store.getters['campaigns/getAllCampaigns'].map(i => {
return {
id: i.id,
name: i.title,
};
});
case 'labels':
return this.$store.getters['labels/getLabels'].map(i => {
return {
id: i.title,
name: i.title,
};
});
case 'browser_language':
return languages;
case 'country_code':
return countries;
default:
return undefined;
}
},
appendNewFilter() {
if (this.isFolderView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
if (this.appliedFilters.length <= 1) {
useAlert(this.$t('FILTER.FILTER_DELETE_ERROR'));
} else {
this.appliedFilters.splice(index, 1);
}
},
submitFilterQuery() {
this.validationErrors = validateConversationOrContactFilters(
this.appliedFilters
);
if (Object.keys(this.validationErrors).length === 0) {
this.$store.dispatch(
'setConversationFilters',
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.$emit('applyFilter', this.appliedFilters);
useTrack(CONVERSATION_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
}
},
updateSavedCustomViews() {
this.$emit('updateFolder', this.appliedFilters, this.activeFolderNewName);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
).filterOperators[0].value;
this.appliedFilters[index].values = '';
},
showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false;
return true;
},
},
};
</script>
<template>
<div>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p class="text-slate-600 dark:text-slate-200">
{{ filterModalSubTitle }}
</p>
</woot-modal-header>
<div class="p-8">
<div v-if="isFolderView">
<label class="input-label" :class="{ error: !activeFolderNewName }">
{{ $t('FILTER.FOLDER_LABEL') }}
<input
v-model="activeFolderNewName"
type="text"
class="bg-white folder-input border-slate-75 dark:border-slate-600 dark:bg-slate-900 text-slate-900 dark:text-slate-100"
/>
<span v-if="!activeFolderNewName" class="message">
{{ $t('FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="mb-1">
{{ $t('FILTER.FOLDER_QUERY_LABEL') }}
</label>
</div>
<div
class="p-4 mb-4 border border-solid rounded-lg bg-slate-25 dark:bg-slate-900 border-slate-50 dark:border-slate-700/50"
>
<FilterInputBox
v-for="(filter, i) in appliedFilters"
:key="i"
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
:input-type="
getInputType(
appliedFilters[i].attribute_key,
appliedFilters[i].filter_operator
)
"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@reset-filter="resetFilter(i, appliedFilters[i])"
@remove-filter="removeFilter(i)"
/>
<div class="mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewFilter"
>
{{ $t('FILTER.ADD_NEW_FILTER') }}
</woot-button>
</div>
</div>
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isFolderView"
:disabled="!activeFolderNewName"
@click="updateSavedCustomViews"
>
{{ $t('FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.folder-input {
@apply w-[50%];
}
</style>
@@ -1,14 +1,14 @@
<script>
import { mapGetters } from 'vuex';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import ConversationHeader from './ConversationHeader.vue';
import DashboardAppFrame from '../DashboardApp/Frame.vue';
import EmptyState from './EmptyState/EmptyState.vue';
import MessagesView from './MessagesView.vue';
import ConversationSidebar from './ConversationSidebar.vue';
export default {
components: {
ContactPanel,
ConversationSidebar,
ConversationHeader,
DashboardAppFrame,
EmptyState,
@@ -98,8 +98,10 @@ export default {
<template>
<div
class="conversation-details-wrap"
:class="{ 'with-border-right': !isOnExpandedLayout }"
class="conversation-details-wrap bg-n-background"
:class="{
'border-l rtl:border-l-0 rtl:border-r border-n-weak': !isOnExpandedLayout,
}"
>
<ConversationHeader
v-if="currentChat.id"
@@ -138,17 +140,11 @@ export default {
v-if="!currentChat.id && !isInboxView"
:is-on-expanded-layout="isOnExpandedLayout"
/>
<div
v-show="showContactPanel"
class="conversation-sidebar-wrap basis-full sm:basis-[17.5rem] md:basis-[18.75rem] lg:basis-[19.375rem] xl:basis-[20.625rem] 2xl:basis-[25rem] rtl:border-r border-slate-50 dark:border-slate-700 h-auto overflow-auto z-10 flex-shrink-0 flex-grow-0"
>
<ContactPanel
v-if="showContactPanel"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="onToggleContactPanel"
/>
</div>
<ConversationSidebar
v-if="showContactPanel"
:current-chat="currentChat"
@toggle-contact-panel="onToggleContactPanel"
/>
</div>
<DashboardAppFrame
v-for="(dashboardApp, index) in dashboardApps"
@@ -165,10 +161,6 @@ export default {
<style lang="scss" scoped>
.conversation-details-wrap {
@apply flex flex-col min-w-0 w-full;
&.with-border-right {
@apply border-r border-slate-50 dark:border-slate-700;
}
}
.dashboard-app--tabs {
@@ -180,10 +172,4 @@ export default {
}
}
}
.conversation-sidebar-wrap {
&::v-deep .contact--panel {
@apply w-full h-full max-w-full;
}
}
</style>
@@ -0,0 +1,79 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed, ref } from 'vue';
import CopilotContainer from '../../copilot/CopilotContainer.vue';
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import { useI18n } from 'vue-i18n';
defineProps({
currentChat: {
required: true,
type: Object,
},
});
const emit = defineEmits(['toggleContactPanel']);
const getters = useStoreGetters();
const captainIntegration = computed(() =>
getters['integrations/getIntegration'].value('captain', null)
);
const { t } = useI18n();
const CONTACT_TABS_OPTIONS = [
{ key: 'CONTACT', value: 'contact' },
{ key: 'COPILOT', value: 'copilot' },
];
const tabs = computed(() => {
return CONTACT_TABS_OPTIONS.map(tab => ({
label: t(`CONVERSATION.SIDEBAR.${tab.key}`),
value: tab.value,
}));
});
const activeTab = ref(0);
const toggleContactPanel = () => {
emit('toggleContactPanel');
};
const handleTabChange = selectedTab => {
activeTab.value = tabs.value.findIndex(
tabItem => tabItem.value === selectedTab.value
);
};
const showCopilotTab = computed(() => {
return captainIntegration.value && captainIntegration.value.enabled;
});
</script>
<template>
<div
class="ltr:border-l rtl:border-r border-n-weak h-full overflow-hidden z-10 min-w-[300px] w-[300px] 2xl:min-w-96 2xl:w-96 flex flex-col bg-n-background"
>
<div v-if="showCopilotTab" class="p-2">
<TabBar
:tabs="tabs"
:initial-active-tab="activeTab"
class="w-full [&>button]:w-full"
@tab-changed="handleTabChange"
/>
</div>
<div class="overflow-auto flex flex-1">
<ContactPanel
v-if="!activeTab"
:conversation-id="currentChat.id"
:inbox-id="currentChat.inbox_id"
:on-toggle="toggleContactPanel"
/>
<CopilotContainer
v-else-if="activeTab === 1 && showCopilotTab"
:key="currentChat.id"
:conversation-id="currentChat.id"
class="flex-1"
/>
</div>
</div>
</template>
@@ -468,9 +468,7 @@ export default {
size="tiny"
color-scheme="secondary"
class="box-border fixed z-10 bg-white border border-r-0 border-solid rounded-bl-calc rtl:rotate-180 rounded-tl-calc dark:bg-slate-700 border-slate-50 dark:border-slate-600"
:class="
isInboxView ? 'top-52 md:top-40' : 'top-[9.5rem] md:top-[6.25rem]'
"
:class="isInboxView ? 'top-52 md:top-40' : 'top-32'"
:icon="isRightOrLeftIcon"
@click="onToggleContactPanel"
/>
@@ -558,6 +556,7 @@ export default {
<style scoped>
@tailwind components;
@layer components {
.rounded-bl-calc {
border-bottom-left-radius: calc(1.5rem + 1px);
@@ -0,0 +1,146 @@
import { useFileUpload } from '../useFileUpload';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
const mockTranslate = vi.fn();
const mockFile = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: true } },
};
return getterMap[getter];
});
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
});
it('should handle direct file upload when enabled', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
const mockBlob = { signed_id: 'test-blob' };
DirectUpload.mockImplementation(() => ({
create: callback => callback(null, mockBlob),
}));
onFileUpload(mockFile);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
expect.any(Object)
);
expect(mockAttachFile).toHaveBeenCalledWith({
file: mockFile,
blob: mockBlob,
});
});
it('should handle indirect file upload when direct upload is disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': { value: { directUploadsEnabled: false } },
};
return getterMap[getter];
});
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('should show alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should use different max file size for Twilio SMS channel', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: true,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(checkFileSizeLimit).toHaveBeenCalledWith(
mockFile,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
);
});
it('should handle direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith(mockError);
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('should do nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
isATwilioSMSChannel: false,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import {
MAXIMUM_FILE_UPLOAD_SIZE,
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
} from 'shared/constants/messages';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
/**
* Composable for handling file uploads in conversations
* @param {Object} options - Configuration options
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
* @param {Function} options.attachFile - Callback function to handle file attachment
* @returns {Object} File upload methods and utilities
*/
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const maxFileSize = computed(() =>
isATwilioSMSChannel
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
: MAXIMUM_FILE_UPLOAD_SIZE
);
const handleDirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const handleIndirectFileUpload = file => {
if (!file) return;
if (checkFileSizeLimit(file, maxFileSize.value)) {
attachFile({ file });
} else {
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
})
);
}
};
const onFileUpload = file => {
if (globalConfig.value.directUploadsEnabled) {
handleDirectFileUpload(file);
} else {
handleIndirectFileUpload(file);
}
};
return {
onFileUpload,
};
};
@@ -35,7 +35,7 @@ export default {
HELP_CENTER_DOCS_URL:
'https://www.chatwoot.com/docs/product/others/help-center',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024,
SMALL_SCREEN_BREAKPOINT: 1200,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
SNOOZE_OPTIONS: {
UNTIL_NEXT_REPLY: 'until_next_reply',
+24 -8
View File
@@ -11,7 +11,7 @@ export const INBOX_TYPES = {
SMS: 'Channel::Sms',
};
const INBOX_ICON_MAP = {
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
@@ -22,7 +22,20 @@ const INBOX_ICON_MAP = {
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
};
const DEFAULT_ICON = 'i-ri-chat-1-fill';
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.WEB]: 'i-ri-global-line',
[INBOX_TYPES.FB]: 'i-ri-messenger-line',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-line',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-line',
[INBOX_TYPES.API]: 'i-ri-cloudy-line',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-line',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
[INBOX_TYPES.LINE]: 'i-ri-line-line',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
@@ -110,15 +123,18 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, phoneNumber) => {
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO) {
return phoneNumber?.startsWith('whatsapp')
? 'i-ri-whatsapp-fill'
: 'i-ri-chat-1-fill';
if (type === INBOX_TYPES.TWILIO && phoneNumber?.startsWith('whatsapp')) {
return iconMap[INBOX_TYPES.WHATSAPP];
}
return INBOX_ICON_MAP[type] ?? DEFAULT_ICON;
return iconMap[type] ?? defaultIcon;
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
@@ -41,70 +41,112 @@ describe('#Inbox Helpers', () => {
});
describe('getInboxIconByType', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
describe('fill variant (default)', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
describe('line variant', () => {
it('returns correct line icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB, null, 'line')).toBe(
'i-ri-global-line'
);
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
it('returns correct line icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB, null, 'line')).toBe(
'i-ri-messenger-line'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'
);
});
});
describe('Twilio cases', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
describe('fill variant', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
describe('line variant', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(
INBOX_TYPES.TWILIO,
'whatsapp:+1234567890',
'line'
)
).toBe('i-ri-whatsapp-line');
});
it('returns SMS line icon for regular Twilio number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
).toBe('i-ri-chat-1-line');
});
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
@@ -1,46 +1,5 @@
import { describe, it, expect } from 'vitest';
import {
validateConversationOrContactFilters,
validateAutomation,
} from '../validations';
describe('validateConversationOrContactFilters', () => {
it('should return no errors for valid filters', () => {
const validFilters = [
{ attribute_key: 'name', filter_operator: 'contains', values: 'John' },
{ attribute_key: 'email', filter_operator: 'is_present' },
];
const errors = validateConversationOrContactFilters(validFilters);
expect(errors).toEqual({});
});
it('should return errors for invalid filters', () => {
const invalidFilters = [
{ attribute_key: '', filter_operator: 'contains', values: 'John' },
{ attribute_key: 'email', filter_operator: '' },
{ attribute_key: 'age', filter_operator: 'equals' },
];
const errors = validateConversationOrContactFilters(invalidFilters);
expect(errors).toEqual({
filter_0: 'ATTRIBUTE_KEY_REQUIRED',
filter_1: 'FILTER_OPERATOR_REQUIRED',
filter_2: 'VALUE_REQUIRED',
});
});
it('should validate days_before operator correctly', () => {
const filters = [
{ attribute_key: 'date', filter_operator: 'days_before', values: '0' },
{ attribute_key: 'date', filter_operator: 'days_before', values: '999' },
{ attribute_key: 'date', filter_operator: 'days_before', values: '500' },
];
const errors = validateConversationOrContactFilters(filters);
expect(errors).toEqual({
filter_0: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
filter_1: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
});
});
});
import { validateAutomation } from '../validations';
describe('validateAutomation', () => {
it('should return no errors for a valid automation', () => {
@@ -65,29 +65,6 @@ export const validateSingleFilter = filter => {
return null;
};
/**
* Validates filters for conversations or contacts.
*
* @param {Array} filters - An array of filter objects to validate.
* @param {string} filters[].attribute_key - The key of the attribute to filter on.
* @param {string} filters[].filter_operator - The operator to use for filtering.
* @param {string|number} [filters[].values] - The value(s) to filter by (required for most operators).
*
* @returns {Object} An object containing any validation errors, keyed by filter index.
*/
export const validateConversationOrContactFilters = filters => {
const errors = {};
filters.forEach((filter, index) => {
const error = validateSingleFilter(filter);
if (error) {
errors[`filter_${index}`] = error;
}
});
return errors;
};
// ------------------------------------------------------------------
// ---------------------- Automation Validation ---------------------
// ------------------------------------------------------------------
@@ -12,7 +12,8 @@
},
"DROPDOWN_MENU": {
"SEARCH_PLACEHOLDER": "Search...",
"EMPTY_STATE": "No results found."
"EMPTY_STATE": "No results found.",
"SEARCHING": "Searching..."
},
"DIALOG": {
"BUTTONS": {
@@ -57,46 +57,6 @@
"TITLE": "Edit contact",
"DESC": "Edit contact details"
},
"CREATE_CONTACT": {
"BUTTON_LABEL": "New Contact",
"TITLE": "Create new contact",
"DESC": "Add basic information details about the contact."
},
"IMPORT_CONTACTS": {
"BUTTON_LABEL": "Import",
"TITLE": "Import Contacts",
"DESC": "Import contacts through a CSV file.",
"DOWNLOAD_LABEL": "Download a sample csv.",
"FORM": {
"LABEL": "CSV File",
"SUBMIT": "Import",
"CANCEL": "Cancel"
},
"SUCCESS_MESSAGE": "You will be notified via email when the import is complete.",
"ERROR_MESSAGE": "There was an error, please try again"
},
"EXPORT_CONTACTS": {
"BUTTON_LABEL": "Export",
"TITLE": "Export Contacts",
"DESC": "Export contacts to a CSV file.",
"SUCCESS_MESSAGE": "Export is in progress. You will be notified on email when the export file is ready to download.",
"ERROR_MESSAGE": "There was an error, please try again",
"CONFIRM": {
"TITLE": "Export Contacts",
"MESSAGE": "Are you sure you want to export all contacts?",
"FILTERED_MESSAGE": "Are you sure you want to export all the filtered contacts?",
"YES": "Yes, Export",
"NO": "No, Cancel"
}
},
"DELETE_NOTE": {
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you want sure to delete this note?",
"YES": "Yes, Delete it",
"NO": "No, Keep it"
}
},
"DELETE_CONTACT": {
"BUTTON_LABEL": "Delete Contact",
"TITLE": "Delete contact",
@@ -224,79 +184,14 @@
}
},
"CONTACTS_PAGE": {
"HEADER": "Contacts",
"FIELDS": "Contact fields",
"SEARCH_BUTTON": "Search",
"SEARCH_INPUT_PLACEHOLDER": "Search for contacts",
"FILTER_CONTACTS": "Filter",
"FILTER_CONTACTS_SAVE": "Save filter",
"FILTER_CONTACTS_DELETE": "Delete filter",
"FILTER_CONTACTS_EDIT": "Edit segment",
"LIST": {
"LOADING_MESSAGE": "Loading contacts...",
"404": "No contacts matches your search 🔍",
"NO_CONTACTS": "There are no available contacts",
"TABLE_HEADER": {
"NAME": "Name",
"PHONE_NUMBER": "Phone Number",
"CONVERSATIONS": "Conversations",
"LAST_ACTIVITY": "Last Activity",
"CREATED_AT": "Created At",
"COUNTRY": "Country",
"CITY": "City",
"SOCIAL_PROFILES": "Social Profiles",
"COMPANY": "Company",
"EMAIL_ADDRESS": "Email Address"
},
"VIEW_DETAILS": "View details"
}
},
"CONTACT_PROFILE": {
"BACK_BUTTON": "Contacts",
"LOADING": "Loading contact profile..."
},
"REMINDER": {
"ADD_BUTTON": {
"BUTTON": "Add",
"TITLE": "Shift + Enter to create a task"
},
"FOOTER": {
"DUE_DATE": "Due date",
"LABEL_TITLE": "Set type"
}
},
"NOTES": {
"FETCHING_NOTES": "Fetching notes...",
"NOT_AVAILABLE": "There are no notes created for this contact",
"HEADER": {
"TITLE": "Notes"
},
"LIST": {
"LABEL": "added a note"
},
"ADD": {
"BUTTON": "Add",
"PLACEHOLDER": "Add a note",
"TITLE": "Shift + Enter to create a note"
},
"CONTENT_HEADER": {
"DELETE": "Delete note"
}
},
"EVENTS": {
"HEADER": {
"TITLE": "Activities"
},
"BUTTON": {
"PILL_BUTTON_NOTES": "notes",
"PILL_BUTTON_EVENTS": "events",
"PILL_BUTTON_CONVO": "conversations"
"SOCIAL_PROFILES": "Social Profiles"
}
}
},
"CUSTOM_ATTRIBUTES": {
"ADD_BUTTON_TEXT": "Add attributes",
"BUTTON": "Add custom attribute",
"NOT_AVAILABLE": "There are no custom attributes available for this contact.",
"COPY_SUCCESSFUL": "Copied to clipboard successfully",
"SHOW_MORE": "Show all attributes",
"SHOW_LESS": "Show less attributes",
@@ -393,6 +288,7 @@
"SEARCH_TITLE": "Search contacts",
"SEARCH_PLACEHOLDER": "Search...",
"MESSAGE_BUTTON": "Message",
"SEND_MESSAGE": "Send message",
"BREADCRUMB": {
"CONTACTS": "Contacts"
},
@@ -653,5 +549,53 @@
"SEARCH_EMPTY_STATE_TITLE": "No contacts matches your search 🔍",
"LIST_EMPTY_STATE_TITLE": "No contacts available in this view 📋"
}
},
"COMPOSE_NEW_CONVERSATION": {
"CONTACT_SEARCH": {
"ERROR_MESSAGE": "We couldnt complete the search. Please try again."
},
"FORM": {
"GO_TO_CONVERSATION": "View",
"SUCCESS_MESSAGE": "The message was sent successfully!",
"ERROR_MESSAGE": "An error occurred while creating the conversation. Please try again later.",
"NO_INBOX_ALERT": "There are no available inboxes to start a conversation with this contact.",
"CONTACT_SELECTOR": {
"LABEL": "To:",
"TAG_INPUT_PLACEHOLDER": "Search for a contact with name, email or phone number",
"CONTACT_CREATING": "Creating contact..."
},
"INBOX_SELECTOR": {
"LABEL": "Via:",
"BUTTON": "Show inboxes"
},
"EMAIL_OPTIONS": {
"SUBJECT_LABEL": "Subject :",
"SUBJECT_PLACEHOLDER": "Enter your email subject here",
"CC_LABEL": "Cc:",
"CC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Search for a contact with their email address",
"BCC_BUTTON": "Bcc"
},
"MESSAGE_EDITOR": {
"PLACEHOLDER": "Write your message here..."
},
"WHATSAPP_OPTIONS": {
"LABEL": "Select template",
"SEARCH_PLACEHOLDER": "Search templates",
"EMPTY_STATE": "No templates found",
"TEMPLATE_PARSER": {
"TEMPLATE_NAME": "WhatsApp template: {templateName}",
"VARIABLES": "Variables",
"BACK": "Go back",
"SEND_MESSAGE": "Send message"
}
},
"ACTION_BUTTONS": {
"DISCARD": "Discard",
"SEND": "Send ({keyCode})"
}
}
}
}
@@ -219,6 +219,10 @@
"DELETE": "Delete",
"CANCEL": "Cancel"
}
},
"SIDEBAR": {
"CONTACT": "Contact",
"COPILOT": "Copilot"
}
},
"EMAIL_TRANSCRIPT": {
@@ -299,5 +299,13 @@
"ERROR": "There was an error unlinking the issue, please try again"
}
}
},
"CAPTAIN": {
"NAME": "Captain",
"COPILOT": {
"SEND_MESSAGE": "Send message...",
"LOADER": "Captain is thinking",
"YOU": "You"
}
}
}
@@ -1,109 +0,0 @@
<script>
import Modal from 'dashboard/components/Modal.vue';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
export default {
components: {
Modal,
},
props: {
show: {
type: Boolean,
default: false,
},
isCreating: {
type: Boolean,
default: false,
},
},
emits: ['create', 'cancel', 'update:show'],
setup() {
return { v$: useVuelidate() };
},
data() {
return {
attributeValue: '',
attributeName: '',
};
},
validations: {
attributeName: {
required,
minLength: minLength(2),
},
},
computed: {
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
attributeNameError() {
if (this.v$.attributeName.$error) {
return this.$t('CUSTOM_ATTRIBUTES.FORM.NAME.ERROR');
}
return '';
},
},
methods: {
addCustomAttribute() {
this.$emit('create', {
attributeValue: this.attributeValue,
attributeName: this.attributeName || '',
});
this.reset();
this.$emit('cancel');
},
onClose() {
this.reset();
this.$emit('cancel');
},
reset() {
this.attributeValue = '';
this.attributeName = '';
},
},
};
</script>
<template>
<Modal v-model:show="localShow" :on-close="onClose">
<woot-modal-header
:header-title="$t('CUSTOM_ATTRIBUTES.ADD.TITLE')"
:header-content="$t('CUSTOM_ATTRIBUTES.ADD.DESC')"
/>
<form class="w-full" @submit.prevent="addCustomAttribute">
<woot-input
v-model="attributeName"
:class="{ error: v$.attributeName.$error }"
class="w-full"
:error="attributeNameError"
:label="$t('CUSTOM_ATTRIBUTES.FORM.NAME.LABEL')"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.NAME.PLACEHOLDER')"
@blur="v$.attributeName.$touch"
@input="v$.attributeName.$touch"
/>
<woot-input
v-model="attributeValue"
class="w-full"
:label="$t('CUSTOM_ATTRIBUTES.FORM.VALUE.LABEL')"
:placeholder="$t('CUSTOM_ATTRIBUTES.FORM.VALUE.PLACEHOLDER')"
/>
<div class="flex items-center justify-end gap-2 px-0 py-2">
<woot-button
:is-disabled="v$.attributeName.$invalid || isCreating"
:is-loading="isCreating"
>
{{ $t('CUSTOM_ATTRIBUTES.FORM.CREATE') }}
</woot-button>
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CUSTOM_ATTRIBUTES.FORM.CANCEL') }}
</woot-button>
</div>
</form>
</Modal>
</template>
@@ -1,131 +0,0 @@
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
export default {
components: {
EmojiOrIcon,
},
props: {
label: { type: String, required: true },
icon: { type: String, default: '' },
emoji: { type: String, default: '' },
value: { type: [String, Number], default: '' },
showEdit: { type: Boolean, default: false },
},
emits: ['update'],
data() {
return {
isEditing: false,
editedValue: this.value,
};
},
methods: {
focusInput() {
this.$refs.inputfield.focus();
},
onEdit() {
this.isEditing = true;
this.$nextTick(() => {
this.focusInput();
});
},
onUpdate() {
this.isEditing = false;
this.$emit('update', this.editedValue);
},
},
};
</script>
<template>
<div class="contact-attribute">
<div class="title-wrap">
<h4 class="text-sm title">
<div class="title--icon">
<EmojiOrIcon :icon="icon" :emoji="emoji" />
</div>
{{ label }}
</h4>
</div>
<div v-show="isEditing">
<div class="mb-2 w-full flex items-center">
<input
ref="inputfield"
v-model="editedValue"
type="text"
class="!h-8 ltr:rounded-r-none rtl:rounded-l-none !mb-0 !text-sm"
autofocus="true"
@keyup.enter="onUpdate"
/>
<div>
<woot-button
size="small"
icon="ion-checkmark"
class="rounded-l-none rtl:rounded-r-none"
@click="onUpdate"
/>
</div>
</div>
</div>
<div
v-show="!isEditing"
class="value--view"
:class="{ 'is-editable': showEdit }"
>
<p class="value">
{{ value || '---' }}
</p>
<woot-button
v-if="showEdit"
variant="clear link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="edit-button"
@click="onEdit"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.contact-attribute {
margin-bottom: var(--space-small);
}
.title-wrap {
display: flex;
align-items: center;
margin-bottom: var(--space-mini);
}
.title {
display: flex;
align-items: center;
margin: 0;
}
.title--icon {
width: var(--space-two);
}
.edit-button {
display: none;
}
.value--view {
display: flex;
&.is-editable:hover {
.value {
background: var(--color-background);
}
.edit-button {
display: block;
}
}
}
.value {
display: inline-block;
min-width: var(--space-mega);
border-radius: var(--border-radius-small);
word-break: break-all;
margin: 0 var(--space-smaller) 0 var(--space-normal);
padding: var(--space-micro) var(--space-smaller);
}
</style>
@@ -1,115 +0,0 @@
<script>
import Attribute from './ContactAttribute.vue';
export default {
components: { Attribute },
props: {
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['update', 'createAttribute'],
computed: {
additionalAttributes() {
return this.contact.additional_attributes || {};
},
company() {
const { company = {} } = this.contact;
return company;
},
customAttributes() {
const { custom_attributes: customAttributes = {} } = this.contact;
return customAttributes;
},
customAttributekeys() {
return Object.keys(this.customAttributes).filter(key => {
const value = this.customAttributes[key];
return value !== null && value !== undefined;
});
},
},
methods: {
onEmailUpdate(value) {
this.$emit('update', { email: value });
},
onPhoneUpdate(value) {
this.$emit('update', { phone_number: value });
},
onLocationUpdate(value) {
this.$emit('update', { location: value });
},
handleCustomCreate() {
this.$emit('createAttribute');
},
onCustomAttributeUpdate(key, value) {
this.$emit('update', { custom_attributes: { [key]: value } });
},
},
};
</script>
<template>
<div class="contact-fields">
<h3 class="text-lg title">
{{ $t('CONTACTS_PAGE.FIELDS') }}
</h3>
<Attribute
:label="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
icon="mail"
emoji=""
:value="contact.email"
show-edit
@update="onEmailUpdate"
/>
<Attribute
:label="$t('CONTACT_PANEL.PHONE_NUMBER')"
icon="call"
emoji=""
:value="contact.phone_number"
show-edit
@update="onPhoneUpdate"
/>
<Attribute
v-if="additionalAttributes.location"
:label="$t('CONTACT_PANEL.LOCATION')"
icon="map"
emoji="🌍"
:value="additionalAttributes.location"
show-edit
@update="onLocationUpdate"
/>
<div
v-for="attribute in customAttributekeys"
:key="attribute"
class="custom-attribute--row"
>
<Attribute
:label="attribute"
icon="chevron-right"
:value="customAttributes[attribute]"
show-edit
@update="value => onCustomAttributeUpdate(attribute, value)"
/>
</div>
<woot-button
size="small"
variant="link"
icon="add"
@click="handleCustomCreate"
>
{{ $t('CUSTOM_ATTRIBUTES.ADD.TITLE') }}
</woot-button>
</div>
</template>
<style scoped lang="scss">
.contact-fields {
margin-top: var(--space-medium);
}
.title {
margin-bottom: var(--space-normal);
}
</style>
@@ -1,106 +0,0 @@
<script>
import EditContact from 'dashboard/routes/dashboard/conversation/contact/EditContact.vue';
import NewConversation from 'dashboard/routes/dashboard/conversation/contact/NewConversation.vue';
import AddCustomAttribute from 'dashboard/modules/contact/components/AddCustomAttribute.vue';
import ContactIntro from './ContactIntro.vue';
import ContactFields from './ContactFields.vue';
export default {
components: {
AddCustomAttribute,
ContactFields,
ContactIntro,
EditContact,
NewConversation,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
},
data() {
return {
showCustomAttributeModal: false,
showEditModal: false,
showConversationModal: false,
};
},
computed: {
enableNewConversation() {
return this.contact && this.contact.id;
},
},
methods: {
toggleCustomAttributeModal() {
this.showCustomAttributeModal = !this.showCustomAttributeModal;
},
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
toggleConversationModal() {
this.showConversationModal = !this.showConversationModal;
},
createCustomAttribute(data) {
const { id } = this.contact;
const { attributeValue, attributeName } = data;
const updatedFields = {
id,
custom_attributes: {
[attributeName]: attributeValue,
},
};
this.updateContact(updatedFields);
},
updateField(data) {
const { id } = this.contact;
const updatedFields = {
id,
...data,
};
this.updateContact(updatedFields);
},
updateContact(contactItem) {
this.$store.dispatch('contacts/update', contactItem);
},
},
};
</script>
<template>
<div class="panel">
<ContactIntro
:contact="contact"
@message="toggleConversationModal"
@edit="toggleEditModal"
/>
<ContactFields
:contact="contact"
@update="updateField"
@create-attribute="toggleCustomAttributeModal"
/>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<NewConversation
v-if="enableNewConversation"
:show="showConversationModal"
:contact="contact"
@cancel="toggleConversationModal"
/>
<AddCustomAttribute
:show="showCustomAttributeModal"
@cancel="toggleCustomAttributeModal"
@create="createCustomAttribute"
/>
</div>
</template>
<style scoped lang="scss">
.panel {
padding: var(--space-normal) var(--space-normal);
}
</style>
@@ -1,50 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import NoteList from './components/NoteList.vue';
export default {
components: {
NoteList,
},
props: {
contactId: {
type: Number,
required: true,
},
},
computed: {
...mapGetters({ uiFlags: 'contactNotes/getUIFlags' }),
notes() {
return this.$store.getters['contactNotes/getAllNotesByContact'](
this.contactId
);
},
},
mounted() {
this.fetchContactNotes();
},
methods: {
fetchContactNotes() {
const { contactId } = this;
if (contactId) this.$store.dispatch('contactNotes/get', { contactId });
},
onAdd(content) {
const { contactId } = this;
this.$store.dispatch('contactNotes/create', { content, contactId });
},
onDelete(noteId) {
const { contactId } = this;
this.$store.dispatch('contactNotes/delete', { noteId, contactId });
},
},
};
</script>
<template>
<NoteList
:is-fetching="uiFlags.isFetching"
:notes="notes"
@add="onAdd"
@delete="onDelete"
/>
</template>
@@ -1,62 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const emit = defineEmits(['add']);
const noteContent = ref('');
const buttonDisabled = computed(() => noteContent.value === '');
const onAdd = () => {
if (noteContent.value !== '') {
emit('add', noteContent.value);
}
noteContent.value = '';
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
</script>
<template>
<div
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100"
>
<WootMessageEditor
v-model="noteContent"
class="input--note"
:placeholder="$t('NOTES.ADD.PLACEHOLDER')"
:enable-suggestions="false"
/>
<div class="flex justify-end w-full">
<woot-button
color-scheme="warning"
:title="$t('NOTES.ADD.TITLE')"
:is-disabled="buttonDisabled"
@click="onAdd"
>
{{ $t('NOTES.ADD.BUTTON') }} {{ '(⌘⏎)' }}
</woot-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.input--note {
&::v-deep .ProseMirror-menubar {
padding: 0;
margin-top: var(--space-minus-small);
}
&::v-deep .ProseMirror-woot-style {
max-height: 22.5rem;
}
}
</style>
@@ -1,139 +0,0 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { dynamicTime } from 'shared/helpers/timeHelper';
export default {
components: {
Thumbnail,
},
props: {
id: {
type: Number,
default: 0,
},
note: {
type: String,
default: '',
},
user: {
type: Object,
default: () => {},
},
createdAt: {
type: Number,
default: 0,
},
},
emits: ['delete'],
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showDeleteModal: false,
};
},
computed: {
readableTime() {
return dynamicTime(this.createdAt);
},
noteAuthor() {
return this.user || {};
},
noteAuthorName() {
return this.noteAuthor.name || this.$t('APP_GLOBAL.DELETED_USER');
},
},
methods: {
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
onDelete() {
this.$emit('delete', this.id);
},
confirmDeletion() {
this.onDelete();
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
},
},
};
</script>
<template>
<div
class="flex flex-col flex-grow p-4 mb-2 overflow-hidden bg-white border border-solid rounded-md shadow-sm border-slate-75 dark:border-slate-700 dark:bg-slate-900 text-slate-700 dark:text-slate-100 note-wrap"
>
<div class="flex items-end justify-between gap-1 text-xs">
<div class="flex items-center">
<Thumbnail
:title="noteAuthorName"
:src="noteAuthor.thumbnail"
:username="noteAuthorName"
size="20px"
/>
<div class="my-0 mx-1 p-0.5 flex flex-row gap-1">
<span class="font-medium text-slate-800 dark:text-slate-100">
{{ noteAuthorName }}
</span>
<span class="text-slate-700 dark:text-slate-100">
{{ $t('NOTES.LIST.LABEL') }}
</span>
<span class="font-medium text-slate-700 dark:text-slate-100">
{{ readableTime }}
</span>
</div>
</div>
<div class="flex invisible actions">
<woot-button
v-tooltip="$t('NOTES.CONTENT_HEADER.DELETE')"
variant="smooth"
size="tiny"
icon="delete"
color-scheme="secondary"
@click="toggleDeleteModal"
/>
</div>
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_NOTE.CONFIRM.TITLE')"
:message="$t('DELETE_NOTE.CONFIRM.MESSAGE')"
:confirm-text="$t('DELETE_NOTE.CONFIRM.YES')"
:reject-text="$t('DELETE_NOTE.CONFIRM.NO')"
/>
</div>
<p
v-dompurify-html="formatMessage(note || '')"
class="mt-4 note__content"
/>
</div>
</template>
<style lang="scss" scoped>
// For RTL direction view
.app-rtl--wrapper {
.note__content {
::v-deep {
p {
unicode-bidi: plaintext;
}
}
}
}
.note-wrap:hover {
.actions {
@apply visible;
}
}
</style>
@@ -1,61 +0,0 @@
<script>
import AddNote from './AddNote.vue';
import ContactNote from './ContactNote.vue';
import Spinner from 'shared/components/Spinner.vue';
export default {
components: {
AddNote,
ContactNote,
Spinner,
},
props: {
notes: {
type: Array,
default: () => [],
},
isFetching: {
type: Boolean,
default: false,
},
},
emits: ['add', 'edit', 'delete'],
methods: {
onAddNote(value) {
this.$emit('add', value);
},
onEditNote(value) {
this.$emit('edit', value);
},
onDeleteNote(value) {
this.$emit('delete', value);
},
},
};
</script>
<template>
<div>
<AddNote @add="onAddNote" />
<ContactNote
v-for="note in notes"
:id="note.id"
:key="note.id"
:note="note.content"
:user="note.user"
:created-at="note.created_at"
@edit="onEditNote"
@delete="onDeleteNote"
/>
<div v-if="isFetching" class="text-center p-4 text-base">
<Spinner size="" />
<span>{{ $t('NOTES.FETCHING_NOTES') }}</span>
</div>
<div v-else-if="!notes.length" class="text-center p-4 text-base">
<span>{{ $t('NOTES.NOT_AVAILABLE') }}</span>
</div>
</div>
</template>
@@ -178,7 +178,6 @@ export default {
<NextSidebar
v-if="showNextSidebar"
@toggle-account-modal="toggleAccountModal"
@open-notification-panel="openNotificationPanel"
@open-key-shortcut-modal="toggleKeyShortcutModal"
@close-key-shortcut-modal="closeKeyShortcutModal"
@show-create-account-modal="openCreateAccountModal"
@@ -1,140 +0,0 @@
<script>
export default {
props: {
options: {
type: Array,
default: () => [],
},
},
emits: ['add'],
data() {
return {
content: '',
date: '',
label: '',
};
},
computed: {
buttonDisabled() {
return this.content && this.date === '';
},
},
methods: {
resetValue() {
this.content = '';
this.date = '';
},
optionSelected(event) {
this.label = event.target.value;
},
onAdd() {
const task = {
content: this.content,
date: this.date,
label: this.label,
};
this.$emit('add', task);
this.resetValue();
},
},
};
</script>
<template>
<div class="wrap">
<div class="input-select-wrap">
<textarea
v-model="content"
class="input--reminder"
@keydown.enter.shift.exact="onAdd"
/>
<div class="select-wrap">
<div class="select">
<div class="input-group">
<i class="ion-android-calendar input-group-label" />
<input
v-model="date"
type="text"
:placeholder="$t('REMINDER.FOOTER.DUE_DATE')"
class="input-group-field"
/>
</div>
<div class="task-wrap">
<select class="task__type" @change="optionSelected($event)">
<option value="" disabled selected>
{{ $t('REMINDER.FOOTER.LABEL_TITLE') }}
</option>
<option
v-for="option in options"
:key="option.id"
:value="option.id"
>
{{ option.name }}
</option>
</select>
</div>
</div>
<woot-button
size="tiny"
color-scheme="primary"
class-names="add-button"
:title="$t('REMINDER.ADD_BUTTON.TITLE')"
:is-disabled="buttonDisabled"
@click="onAdd"
>
{{ $t('REMINDER.ADD_BUTTON.BUTTON') }}
</woot-button>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrap {
display: flex;
margin-bottom: var(--space-smaller);
width: 100%;
.input-select-wrap {
padding: var(--space-small) var(--space-small);
width: 100%;
.input--reminder {
font-size: var(--font-size-mini);
margin-bottom: var(--space-small);
resize: none;
}
.select-wrap {
display: flex;
justify-content: space-between;
.select {
display: flex;
}
}
.input-group {
margin-bottom: 0;
font-size: var(--font-size-mini);
.input-group-field {
height: var(--space-medium);
font-size: var(--font-size-micro);
}
}
.task-wrap {
.task__type {
margin: 0 0 0 var(--space-smaller);
height: var(--space-medium);
width: fit-content;
padding: 0 var(--space-two) 0 var(--space-smaller);
font-size: var(--font-size-micro);
}
}
}
}
</style>
@@ -1,175 +0,0 @@
<script>
import AccordionItem from 'dashboard/components/Accordion/AccordionItem.vue';
import ContactConversations from 'dashboard/routes/dashboard/conversation/ContactConversations.vue';
import ContactInfo from 'dashboard/routes/dashboard/conversation/contact/ContactInfo.vue';
import ContactLabel from 'dashboard/routes/dashboard/contacts/components/ContactLabels.vue';
import CustomAttributes from 'dashboard/routes/dashboard/conversation/customAttributes/CustomAttributes.vue';
import Draggable from 'vuedraggable';
import { useUISettings } from 'dashboard/composables/useUISettings';
export default {
components: {
AccordionItem,
ContactConversations,
ContactInfo,
ContactLabel,
CustomAttributes,
Draggable,
},
props: {
contact: {
type: Object,
default: () => ({}),
},
onClose: {
type: Function,
default: () => {},
},
showAvatar: {
type: Boolean,
default: true,
},
showCloseButton: {
type: Boolean,
default: true,
},
},
setup() {
const {
updateUISettings,
isContactSidebarItemOpen,
contactSidebarItemsOrder,
toggleSidebarUIState,
} = useUISettings();
return {
updateUISettings,
isContactSidebarItemOpen,
contactSidebarItemsOrder,
toggleSidebarUIState,
};
},
data() {
return {
dragEnabled: true,
contactSidebarItems: [],
dragging: false,
};
},
computed: {
hasContactAttributes() {
const { custom_attributes: customAttributes } = this.contact;
return customAttributes && Object.keys(customAttributes).length;
},
},
mounted() {
this.contactSidebarItems = this.contactSidebarItemsOrder;
},
methods: {
onDragEnd() {
this.dragging = false;
this.updateUISettings({
contact_sidebar_items_order: this.contactSidebarItems,
});
},
},
};
</script>
<template>
<div
class="relative w-1/4 h-full min-h-screen overflow-y-auto text-sm bg-white dark:bg-slate-900 border-slate-50 dark:border-slate-800/50"
:class="showAvatar ? 'border-l border-solid ' : 'border-r border-solid'"
>
<ContactInfo
:show-close-button="showCloseButton"
:show-avatar="showAvatar"
:contact="contact"
close-icon-name="dismiss"
@panel-close="onClose"
@toggle-panel="onClose"
/>
<div class="list-group">
<Draggable
:list="contactSidebarItems"
:disabled="!dragEnabled"
ghost-class="ghost"
item-key="name"
@start="dragging = true"
@end="onDragEnd"
>
<template #item="{ element }">
<div :key="element.name" class="list-group-item">
<div v-if="element.name === 'contact_attributes'">
<AccordionItem
:title="$t('CONVERSATION_SIDEBAR.ACCORDION.CONTACT_ATTRIBUTES')"
:is-open="isContactSidebarItemOpen('is_ct_custom_attr_open')"
compact
@toggle="
value => toggleSidebarUIState('is_ct_custom_attr_open', value)
"
>
<CustomAttributes
:contact-id="contact.id"
attribute-type="contact_attribute"
attribute-from="contact_panel"
:custom-attributes="contact.custom_attributes"
:empty-state-message="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.NO_RECORDS_FOUND')
"
/>
</AccordionItem>
</div>
<div v-if="element.name === 'contact_labels'">
<AccordionItem
:title="$t('CONTACT_PANEL.SIDEBAR_SECTIONS.CONTACT_LABELS')"
:is-open="isContactSidebarItemOpen('is_ct_labels_open')"
@toggle="
value => toggleSidebarUIState('is_ct_labels_open', value)
"
>
<ContactLabel :contact-id="contact.id" class="contact-labels" />
</AccordionItem>
</div>
<div v-if="element.name === 'previous_conversation'">
<AccordionItem
:title="
$t('CONTACT_PANEL.SIDEBAR_SECTIONS.PREVIOUS_CONVERSATIONS')
"
:is-open="isContactSidebarItemOpen('is_ct_prev_conv_open')"
compact
@toggle="
value => toggleSidebarUIState('is_ct_prev_conv_open', value)
"
>
<ContactConversations
v-if="contact.id"
:contact-id="contact.id"
conversation-id=""
/>
</AccordionItem>
</div>
</div>
</template>
</Draggable>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.contact--profile {
@apply pb-3 mb-4;
}
}
.list-group {
.list-group-item {
@apply bg-white dark:bg-slate-900;
}
}
.conversation--details {
@apply py-0 px-4;
}
</style>
@@ -1,85 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import LabelSelector from 'dashboard/components/widgets/LabelSelector.vue';
export default {
components: { LabelSelector },
props: {
contactId: {
type: [String, Number],
required: true,
},
},
computed: {
savedLabels() {
const availableContactLabels = this.$store.getters[
'contactLabels/getContactLabels'
](this.contactId);
return this.allLabels.filter(({ title }) =>
availableContactLabels.includes(title)
);
},
...mapGetters({
allLabels: 'labels/getLabels',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.fetchLabels(newContactId);
}
},
},
mounted() {
const { contactId } = this;
this.fetchLabels(contactId);
},
methods: {
async onUpdateLabels(selectedLabels) {
try {
await this.$store.dispatch('contactLabels/update', {
contactId: this.contactId,
labels: selectedLabels,
});
} catch (error) {
useAlert(this.$t('CONTACT_PANEL.LABELS.CONTACT.ERROR'));
}
},
addItem(value) {
const result = this.savedLabels.map(item => item.title);
result.push(value.title);
this.onUpdateLabels(result);
},
removeItem(value) {
const result = this.savedLabels
.map(label => label.title)
.filter(label => label !== value);
this.onUpdateLabels(result);
},
async fetchLabels(contactId) {
if (!contactId) {
return;
}
this.$store.dispatch('contactLabels/get', contactId);
},
},
};
</script>
<template>
<LabelSelector
:all-labels="allLabels"
:saved-labels="savedLabels"
@add="addItem"
@remove="removeItem"
/>
</template>
@@ -1,368 +0,0 @@
<script>
import { useAlert } from 'dashboard/composables';
import FilterInputBox from '../../../../components/widgets/FilterInput/Index.vue';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from '../contactFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
import { useTrack } from 'dashboard/composables';
export default {
components: {
FilterInputBox,
},
props: {
onClose: {
type: Function,
default: () => {},
},
initialFilterTypes: {
type: Array,
default: () => [],
},
initialAppliedFilters: {
type: Array,
default: () => [],
},
isSegmentsView: {
type: Boolean,
default: false,
},
activeSegmentName: {
type: String,
default: '',
},
},
emits: ['applyFilter', 'clearFilters', 'updateSegment'],
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'CONTACTS_FILTER',
attributeModel: 'contact_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
appliedFilters: this.initialAppliedFilters,
activeSegmentNewName: this.activeSegmentName,
filterTypes: this.initialFilterTypes,
filterGroups: [],
allCustomAttributes: [],
filterAttributeGroups,
attributeModel: 'contact_attribute',
filtersFori18n: 'CONTACTS_FILTER',
validationErrors: {},
};
},
computed: {
...mapGetters({
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
filterModalHeaderTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.TITLE')
: this.$t('CONTACTS_FILTER.EDIT_CUSTOM_SEGMENT');
},
filterModalSubTitle() {
return !this.isSegmentsView
? this.$t('CONTACTS_FILTER.SUBTITLE')
: this.$t('CONTACTS_FILTER.CUSTOM_VIEWS_SUBTITLE');
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
if (this.getAppliedContactFilters.length && !this.isSegmentsView) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else if (!this.isSegmentsView) {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
attribute_model: 'standard',
});
}
},
methods: {
getOperatorTypes(key) {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
},
customAttributeInputType(key) {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
},
getAttributeModel(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type.attributeModel;
},
getInputType(key, operator) {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
},
getOperators(key) {
const type = this.filterTypes.find(filter => filter.attributeKey === key);
return type?.filterOperators;
},
getDropdownValues(type) {
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const isCustomAttributeCheckbox = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type &&
attr.attribute_display_type === 'checkbox'
);
});
if (isCustomAttributeCheckbox || type === 'blocked') {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
}
const isCustomAttributeList = allCustomAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
if (isCustomAttributeList) {
return allCustomAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
}
switch (type) {
case 'country_code':
return countries;
default:
return undefined;
}
},
appendNewFilter() {
if (this.isSegmentsView) {
this.setQueryOperatorOnLastQuery();
} else {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
}
},
setQueryOperatorOnLastQuery() {
const lastItemIndex = this.appliedFilters.length - 1;
this.appliedFilters[lastItemIndex] = {
...this.appliedFilters[lastItemIndex],
query_operator: 'and',
};
this.$nextTick(() => {
this.appliedFilters.push({
attribute_key: 'name',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
});
});
},
removeFilter(index) {
if (this.appliedFilters.length <= 1) {
useAlert(this.$t('CONTACTS_FILTER.FILTER_DELETE_ERROR'));
} else {
this.appliedFilters.splice(index, 1);
}
},
submitFilterQuery() {
this.validationErrors = validateConversationOrContactFilters(
this.appliedFilters
);
if (Object.keys(this.validationErrors).length === 0) {
this.$store.dispatch(
'contacts/setContactFilters',
JSON.parse(JSON.stringify(this.appliedFilters))
);
this.$emit('applyFilter', this.appliedFilters);
useTrack(CONTACTS_EVENTS.APPLY_FILTER, {
applied_filters: this.appliedFilters.map(filter => ({
key: filter.attribute_key,
operator: filter.filter_operator,
query_operator: filter.query_operator,
})),
});
}
},
updateSegment() {
this.$emit(
'updateSegment',
this.appliedFilters,
this.activeSegmentNewName
);
},
resetFilter(index, currentFilter) {
this.appliedFilters[index].filter_operator = this.filterTypes.find(
filter => filter.attributeKey === currentFilter.attribute_key
).filterOperators[0].value;
this.appliedFilters[index].values = '';
},
showUserInput(operatorType) {
if (operatorType === 'is_present' || operatorType === 'is_not_present')
return false;
return true;
},
clearFilters() {
this.$emit('clearFilters');
this.onClose();
},
},
};
</script>
<template>
<div>
<woot-modal-header :header-title="filterModalHeaderTitle">
<p class="text-slate-600 dark:text-slate-200">
{{ filterModalSubTitle }}
</p>
</woot-modal-header>
<div class="p-8">
<div v-if="isSegmentsView">
<label class="input-label" :class="{ error: !activeSegmentNewName }">
{{ $t('CONTACTS_FILTER.SEGMENT_LABEL') }}
<input
v-model="activeSegmentNewName"
type="text"
class="bg-white folder-input border-slate-75 dark:border-slate-600 dark:bg-slate-900 text-slate-900 dark:text-slate-100"
/>
<span v-if="!activeSegmentNewName" class="message">
{{ $t('CONTACTS_FILTER.EMPTY_VALUE_ERROR') }}
</span>
</label>
<label class="input-label">
{{ $t('CONTACTS_FILTER.SEGMENT_QUERY_LABEL') }}
</label>
</div>
<div
class="p-4 mb-4 border border-solid rounded-lg bg-slate-25 dark:bg-slate-900 border-slate-50 dark:border-slate-700/50"
>
<FilterInputBox
v-for="(filter, i) in appliedFilters"
:key="i"
v-model="appliedFilters[i]"
:filter-groups="filterGroups"
grouped-filters
:input-type="
getInputType(
appliedFilters[i].attribute_key,
appliedFilters[i].filter_operator
)
"
:operators="getOperators(appliedFilters[i].attribute_key)"
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
@reset-filter="resetFilter(i, appliedFilters[i])"
@remove-filter="removeFilter(i)"
/>
<div class="flex items-center gap-2 mt-4">
<woot-button
icon="add"
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewFilter"
>
{{ $t('CONTACTS_FILTER.ADD_NEW_FILTER') }}
</woot-button>
<woot-button
v-if="hasAppliedFilters && !isSegmentsView"
icon="subtract"
color-scheme="alert"
variant="smooth"
size="small"
@click="clearFilters"
>
{{ $t('CONTACTS_FILTER.CLEAR_ALL_FILTERS') }}
</woot-button>
</div>
</div>
<div class="w-full">
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('CONTACTS_FILTER.CANCEL_BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isSegmentsView"
:disabled="!activeSegmentNewName"
@click="updateSegment"
>
{{ $t('CONTACTS_FILTER.UPDATE_BUTTON_LABEL') }}
</woot-button>
<woot-button v-else @click="submitFilterQuery">
{{ $t('CONTACTS_FILTER.SUBMIT_BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.folder-input {
@apply w-[50%];
}
</style>
@@ -1,190 +0,0 @@
<script setup>
import { h, ref, computed, defineEmits } from 'vue';
import {
useVueTable,
createColumnHelper,
getCoreRowModel,
} from '@tanstack/vue-table';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
// Table components
import Table from 'dashboard/components/table/Table.vue';
import NameCell from './ContactsTable/NameCell.vue';
import EmailCell from './ContactsTable/EmailCell.vue';
import TelCell from './ContactsTable/TelCell.vue';
import CountryCell from './ContactsTable/CountryCell.vue';
import ProfilesCell from './ContactsTable/ProfilesCell.vue';
const props = defineProps({
contacts: {
type: Array,
default: () => [],
},
showSearchEmptyState: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['onSortChange']);
const { t } = useI18n();
const tableData = computed(() => {
if (props.isLoading) {
return [];
}
return props.contacts.map(item => {
// Note: The attributes used here is in snake case
// as it simplier the sort attribute calculation
const additional = item.additional_attributes || {};
const { last_activity_at: lastActivityAt } = item;
const { created_at: createdAt } = item;
return {
...item,
profiles: additional.social_profiles || {},
last_activity_at: lastActivityAt ? dynamicTime(lastActivityAt) : null,
created_at: createdAt ? dynamicTime(createdAt) : null,
};
});
});
const defaulSpanRender = cellProps =>
h(
'span',
{
class: cellProps.getValue() ? '' : 'text-slate-300 dark:text-slate-700',
},
cellProps.getValue() ? cellProps.getValue() : '---'
);
const columnHelper = createColumnHelper();
const columns = [
columnHelper.accessor('name', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.NAME'),
cell: cellProps => h(NameCell, cellProps),
size: 250,
}),
columnHelper.accessor('email', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.EMAIL_ADDRESS'),
cell: cellProps => h(EmailCell, { email: cellProps.getValue() }),
size: 250,
}),
columnHelper.accessor('phone_number', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.PHONE_NUMBER'),
size: 200,
cell: cellProps =>
h(TelCell, {
phoneNumber: cellProps.getValue(),
defaultCountry: cellProps.row.original.country_code,
}),
}),
columnHelper.accessor('company_name', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.COMPANY'),
size: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('city', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.CITY'),
cell: defaulSpanRender,
size: 200,
}),
columnHelper.accessor('country', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.COUNTRY'),
size: 200,
cell: cellProps =>
h(CountryCell, {
countryCode: cellProps.row.original.country_code,
country: cellProps.getValue(),
}),
}),
columnHelper.accessor('profiles', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.SOCIAL_PROFILES'),
size: 200,
enableSorting: false,
cell: cellProps =>
h(ProfilesCell, {
profiles: cellProps.getValue(),
}),
}),
columnHelper.accessor('last_activity_at', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.LAST_ACTIVITY'),
size: 200,
cell: defaulSpanRender,
}),
columnHelper.accessor('created_at', {
header: t('CONTACTS_PAGE.LIST.TABLE_HEADER.CREATED_AT'),
size: 200,
cell: defaulSpanRender,
}),
];
// type ColumnSort = {
// id: string
// desc: boolean
// }
// type SortingState = ColumnSort[]
const sortingState = ref([{ last_activity_at: 'desc' }]);
const table = useVueTable({
get data() {
return tableData.value;
},
columns,
enableMultiSort: false,
getCoreRowModel: getCoreRowModel(),
state: {
get sorting() {
return sortingState.value;
},
},
onSortingChange: updater => {
// onSortingChange returns a callback that allows us to trigger the sort when we need
// See more docs here: https://tanstack.com/table/latest/docs/api/features/sorting#onsortingchange
// IMO, I don't like this API, but it's what we have for now. Would be great if we could just listen the the changes
// to the sorting state and emit the event when it changes
// But we can easily wrap this later as a separate composable
const updatedSortState = updater(sortingState.value);
// we pick the first item from the array, as we are not using multi-sorting
const [sort] = updatedSortState;
if (sort) {
sortingState.value = updatedSortState;
emit('onSortChange', { [sort.id]: sort.desc ? 'desc' : 'asc' });
} else {
// If the sorting is empty, we reset to the default sorting
sortingState.value = [{ last_activity_at: 'desc' }];
emit('onSortChange', {});
}
},
});
</script>
<template>
<section class="flex-1 h-full overflow-auto bg-white dark:bg-slate-900">
<section class="overflow-x-auto">
<Table fixed :table="table" type="compact" />
</section>
<EmptyState
v-if="showSearchEmptyState"
:title="$t('CONTACTS_PAGE.LIST.404')"
/>
<EmptyState
v-else-if="!isLoading && !contacts.length"
:title="$t('CONTACTS_PAGE.LIST.NO_CONTACTS')"
/>
<div v-if="isLoading" class="flex items-center justify-center text-base">
<Spinner />
<span>{{ $t('CONTACTS_PAGE.LIST.LOADING_MESSAGE') }}</span>
</div>
</section>
</template>
@@ -1,36 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import { getCountryFlag } from 'dashboard/helper/flag';
const props = defineProps({
countryCode: {
type: String,
default: '',
},
country: {
type: String,
default: '',
},
});
const countryFlag = computed(() => {
if (!props.countryCode) {
return '';
}
return getCountryFlag(props.countryCode);
});
const formattedCountryName = computed(() => {
if (!props.country) {
return '';
}
return `${countryFlag.value} ${props.country}`;
});
</script>
<template>
<BaseCell :content="formattedCountryName" />
</template>
@@ -1,22 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
defineProps({
email: {
type: String,
required: true,
},
});
</script>
<template>
<BaseCell>
<a
target="_blank"
rel="noopener noreferrer nofollow"
class="text-woot-500 dark:text-woot-500"
:href="`mailto:${email}`"
>
{{ email }}
</a>
</BaseCell>
</template>
@@ -1,52 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { inject } from 'vue';
defineProps({
row: {
type: Object,
required: true,
},
});
const route = useRoute();
const openContactInfoPanel = inject('openContactInfoPanel');
const isRTL = useMapGetter('accounts/isRTL');
</script>
<template>
<BaseCell>
<woot-button
variant="clear"
class="!px-0"
@click="() => openContactInfoPanel(row.original.id)"
>
<div class="items-center flex" :class="{ 'flex-row-reverse': isRTL }">
<Thumbnail
size="32px"
:src="row.original.thumbnail"
:username="row.original.name"
:status="row.original.availability_status"
/>
<div class="items-start flex flex-col my-0 mx-2">
<h6 class="overflow-hidden text-base whitespace-nowrap text-ellipsis">
<router-link
:to="`/app/accounts/${route.params.accountId}/contacts/${row.original.id}`"
class="text-sm font-medium m-0 capitalize"
>
{{ row.original.name }}
</router-link>
</h6>
<button
class="button clear small link text-slate-600 dark:text-slate-200"
>
{{ $t('CONTACTS_PAGE.LIST.VIEW_DETAILS') }}
</button>
</div>
</div>
</woot-button>
</BaseCell>
</template>
@@ -1,33 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon.vue';
const props = defineProps({
profiles: {
type: Object,
required: true,
},
});
const filteredProfiles = computed(() =>
Object.keys(props.profiles).filter(profile => props.profiles[profile])
);
</script>
<template>
<BaseCell class="flex gap-2 items-center text-slate-300 dark:text-slate-400">
<template v-if="filteredProfiles.length">
<a
v-for="profile in filteredProfiles"
:key="profile"
:href="`https://${profile}.com/${profiles[profile]}`"
target="_blank"
class="hover:text-slate-500"
rel="noopener noreferrer nofollow"
>
<FluentIcon class="size-4" :icon="`brand-${profile}`" />
</a>
</template>
</BaseCell>
</template>
@@ -1,40 +0,0 @@
<script setup>
import BaseCell from 'dashboard/components/table/BaseCell.vue';
import { computed } from 'vue';
import { parsePhoneNumber } from 'libphonenumber-js';
const props = defineProps({
phoneNumber: {
type: String,
default: '',
},
defaultCountry: {
type: String,
default: '',
},
});
const formattedNumber = computed(() => {
if (!props.phoneNumber) {
return '';
}
try {
const parsedNumber = parsePhoneNumber(
props.phoneNumber,
props.defaultCountry
);
if (parsedNumber) {
return parsedNumber.formatInternational();
}
return props.phoneNumber;
} catch (error) {
return props.phoneNumber;
}
});
</script>
<template>
<BaseCell :content="formattedNumber" />
</template>
@@ -1,475 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import ContactsHeader from './Header.vue';
import ContactsTable from './ContactsTable.vue';
import ContactInfoPanel from './ContactInfoPanel.vue';
import CreateContact from 'dashboard/routes/dashboard/conversation/contact/CreateContact.vue';
import TableFooter from 'dashboard/components/widgets/TableFooter.vue';
import ImportContacts from './ImportContacts.vue';
import ContactsAdvancedFilters from './ContactsAdvancedFilters.vue';
import contactFilterItems from '../contactFilterItems';
import filterQueryGenerator from '../../../../helper/filterQueryGenerator';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import countries from 'shared/constants/countries.js';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
import { useTrack } from 'dashboard/composables';
const DEFAULT_PAGE = 1;
const FILTER_TYPE_CONTACT = 1;
export default {
components: {
ContactsHeader,
ContactsTable,
TableFooter,
ContactInfoPanel,
CreateContact,
ImportContacts,
ContactsAdvancedFilters,
AddCustomViews,
DeleteCustomViews,
},
provide() {
return {
openContactInfoPanel: this.openContactInfoPanel,
};
},
props: {
label: { type: String, default: '' },
segmentsId: {
type: [String, Number],
default: 0,
},
},
data() {
return {
searchQuery: '',
showCreateModal: false,
showImportModal: false,
selectedContactId: '',
sortConfig: { last_activity_at: 'desc' },
showFiltersModal: false,
contactFilterItems: contactFilterItems.map(filter => ({
...filter,
attributeName: this.$t(
`CONTACTS_FILTER.ATTRIBUTES.${filter.attributeI18nKey}`
),
})),
segmentsQuery: {},
filterType: FILTER_TYPE_CONTACT,
showAddSegmentsModal: false,
showDeleteSegmentsModal: false,
appliedFilter: [],
};
},
computed: {
...mapGetters({
records: 'contacts/getContacts',
uiFlags: 'contacts/getUIFlags',
meta: 'contacts/getMeta',
segments: 'customViews/getContactCustomViews',
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
showEmptySearchResult() {
const hasEmptyResults = !!this.searchQuery && this.records.length === 0;
return hasEmptyResults;
},
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
hasActiveSegments() {
return this.activeSegment && this.segmentsId !== 0;
},
isContactAndLabelDashboard() {
return (
this.$route.name === 'contacts_dashboard' ||
this.$route.name === 'contacts_labels_dashboard'
);
},
pageTitle() {
if (this.hasActiveSegments) {
return this.activeSegment.name;
}
if (this.label) {
return `#${this.label}`;
}
return this.$t('CONTACTS_PAGE.HEADER');
},
selectedContact() {
if (this.selectedContactId) {
const contact = this.records.find(
item => this.selectedContactId === item.id
);
return contact;
}
return undefined;
},
showContactViewPane() {
return this.selectedContactId !== '';
},
wrapClass() {
return this.showContactViewPane ? 'w-[75%]' : 'w-full';
},
pageParameter() {
const selectedPageNumber = Number(this.$route.query?.page);
return !Number.isNaN(selectedPageNumber) &&
selectedPageNumber >= DEFAULT_PAGE
? selectedPageNumber
: DEFAULT_PAGE;
},
activeSegment() {
if (this.segmentsId) {
const [firstValue] = this.segments.filter(
view => view.id === Number(this.segmentsId)
);
return firstValue;
}
return undefined;
},
activeSegmentName() {
return this.activeSegment?.name;
},
},
watch: {
label() {
this.fetchContacts(DEFAULT_PAGE);
if (this.hasAppliedFilters) {
this.clearFilters();
}
},
activeSegment() {
if (this.hasActiveSegments) {
const payload = this.activeSegment.query;
this.fetchSavedFilteredContact(payload, DEFAULT_PAGE);
}
if (this.hasAppliedFilters && this.$route.name === 'contacts_dashboard') {
this.fetchFilteredContacts(DEFAULT_PAGE);
} else {
this.fetchContacts(DEFAULT_PAGE);
}
},
},
mounted() {
this.fetchContacts(this.pageParameter);
},
methods: {
updatePageParam(page) {
window.history.pushState({}, null, `${this.$route.path}?page=${page}`);
},
getSortAttribute() {
let sortAttr = Object.keys(this.sortConfig).reduce((acc, sortKey) => {
const sortOrder = this.sortConfig[sortKey];
if (sortOrder) {
const sortOrderSign = sortOrder === 'asc' ? '' : '-';
return `${sortOrderSign}${sortKey}`;
}
return acc;
}, '');
if (!sortAttr) {
this.sortConfig = { last_activity_at: 'desc' };
sortAttr = '-last_activity_at';
}
return sortAttr;
},
fetchContacts(page) {
if (this.isContactAndLabelDashboard) {
this.updatePageParam(page);
let value = '';
if (this.searchQuery.charAt(0) === '+') {
value = this.searchQuery.substring(1);
} else {
value = this.searchQuery;
}
const requestParams = {
page,
sortAttr: this.getSortAttribute(),
label: this.label,
};
if (!value) {
this.$store.dispatch('contacts/get', requestParams);
} else {
this.$store.dispatch('contacts/search', {
search: encodeURIComponent(value),
...requestParams,
});
}
}
},
fetchSavedFilteredContact(payload, page) {
if (this.hasActiveSegments) {
this.updatePageParam(page);
this.$store.dispatch('contacts/filter', {
queryPayload: payload,
page,
});
}
},
fetchFilteredContacts(page) {
if (this.hasAppliedFilters) {
const payload = this.segmentsQuery;
this.updatePageParam(page);
this.$store.dispatch('contacts/filter', {
queryPayload: payload,
page,
});
}
},
onInputSearch(event) {
const newQuery = event.target.value;
const refetchAllContacts = !!this.searchQuery && newQuery === '';
this.searchQuery = newQuery;
if (refetchAllContacts) {
this.fetchContacts(DEFAULT_PAGE);
}
},
onSearchSubmit() {
this.selectedContactId = '';
if (this.searchQuery) {
this.fetchContacts(DEFAULT_PAGE);
}
},
onPageChange(page) {
this.selectedContactId = '';
if (this.segmentsId !== 0) {
const payload = this.activeSegment.query;
this.fetchSavedFilteredContact(payload, page);
}
if (this.hasAppliedFilters) {
this.fetchFilteredContacts(page);
} else {
this.fetchContacts(page);
}
},
openContactInfoPanel(contactId) {
this.selectedContactId = contactId;
this.showContactInfoPanelPane = true;
},
closeContactInfoPanel() {
this.selectedContactId = '';
this.showContactInfoPanelPane = false;
},
onToggleCreate() {
this.showCreateModal = !this.showCreateModal;
},
onToggleSaveFilters() {
this.showAddSegmentsModal = true;
},
onCloseAddSegmentsModal() {
this.showAddSegmentsModal = false;
},
onToggleDeleteFilters() {
this.showDeleteSegmentsModal = true;
},
onCloseDeleteSegmentsModal() {
this.showDeleteSegmentsModal = false;
},
onToggleImport() {
this.showImportModal = !this.showImportModal;
},
onSortChange(params) {
this.sortConfig = params;
this.fetchContacts(this.meta.currentPage);
const sortBy =
Object.entries(params).find(pair => Boolean(pair[1])) || [];
useTrack(CONTACTS_EVENTS.APPLY_SORT, {
appliedOn: sortBy[0],
order: sortBy[1],
});
},
onToggleFilters() {
if (this.hasActiveSegments) {
this.initializeSegmentToFilterModal(this.activeSegment);
}
this.showFiltersModal = true;
},
closeAdvanceFiltersModal() {
this.showFiltersModal = false;
this.appliedFilter = [];
},
onApplyFilter(payload) {
this.closeContactInfoPanel();
this.segmentsQuery = filterQueryGenerator(payload);
this.$store.dispatch('contacts/filter', {
queryPayload: filterQueryGenerator(payload),
});
this.showFiltersModal = false;
},
onUpdateSegment(payload, segmentName) {
const payloadData = {
...this.activeSegment,
name: segmentName,
query: filterQueryGenerator(payload),
};
this.$store.dispatch('customViews/update', payloadData);
this.closeAdvanceFiltersModal();
},
clearFilters() {
this.$store.dispatch('contacts/clearContactFilters');
this.fetchContacts(this.pageParameter);
},
onExportSubmit() {
let query = { payload: [] };
if (this.hasActiveSegments) {
query = this.activeSegment.query;
} else if (this.hasAppliedFilters) {
query = filterQueryGenerator(this.getAppliedContactFilters);
}
try {
this.$store.dispatch('contacts/export', {
...query,
label: this.label,
});
useAlert(this.$t('EXPORT_CONTACTS.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(error.message || this.$t('EXPORT_CONTACTS.ERROR_MESSAGE'));
}
},
setParamsForEditSegmentModal() {
// Here we are setting the params for edit segment modal to show the existing values.
// For custom attributes we get only attribute key.
// So we are mapping it to find the input type of the attribute to show in the edit segment modal.
const params = {
countries: countries,
filterTypes: contactFilterItems,
allCustomAttributes:
this.$store.getters['attributes/getAttributesByModel'](
'contact_attribute'
),
};
return params;
},
initializeSegmentToFilterModal(activeSegment) {
// Here we are setting the params for edit segment modal.
// To show the existing values. when we click on edit segment button.
// Here we get the query from the active segment.
// And we are mapping the query to the actual values.
// To show in the edit segment modal by the help of generateValuesForEditCustomViews helper.
const query = activeSegment?.query?.payload;
if (!Array.isArray(query)) return;
this.appliedFilter.push(
...query.map(filter => ({
attribute_key: filter.attribute_key,
attribute_model: filter.attribute_model,
filter_operator: filter.filter_operator,
values: Array.isArray(filter.values)
? generateValuesForEditCustomViews(
filter,
this.setParamsForEditSegmentModal()
)
: [],
query_operator: filter.query_operator,
custom_attribute_type: filter.custom_attribute_type,
}))
);
},
openSavedItemInSegment() {
const lastItemInSegments = this.segments[this.segments.length - 1];
const lastItemId = lastItemInSegments.id;
this.$router.push({
name: 'contacts_segments_dashboard',
params: { id: lastItemId },
});
},
openLastItemAfterDeleteInSegment() {
if (this.segments.length > 0) {
this.openSavedItemInSegment();
} else {
this.$router.push({ name: 'contacts_dashboard' });
this.fetchContacts(DEFAULT_PAGE);
}
},
},
};
</script>
<template>
<div class="flex flex-row w-full">
<div class="flex flex-col h-full" :class="wrapClass">
<ContactsHeader
:search-query="searchQuery"
:header-title="pageTitle"
:segments-id="segmentsId"
this-selected-contact-id=""
@on-input-search="onInputSearch"
@on-toggle-create="onToggleCreate"
@on-toggle-filter="onToggleFilters"
@on-search-submit="onSearchSubmit"
@on-toggle-import="onToggleImport"
@on-export-submit="onExportSubmit"
@on-toggle-save-filter="onToggleSaveFilters"
@on-toggle-delete-filter="onToggleDeleteFilters"
@on-toggle-edit-filter="onToggleFilters"
/>
<ContactsTable
:contacts="records"
:show-search-empty-state="showEmptySearchResult"
:is-loading="uiFlags.isFetching"
:active-contact-id="selectedContactId"
@on-sort-change="onSortChange"
/>
<TableFooter
class="border-t border-slate-75 dark:border-slate-700/50"
:current-page="Number(meta.currentPage)"
:total-count="meta.count"
:page-size="15"
@page-change="onPageChange"
/>
</div>
<AddCustomViews
v-if="showAddSegmentsModal"
:custom-views-query="segmentsQuery"
:filter-type="filterType"
:open-last-saved-item="openSavedItemInSegment"
@close="onCloseAddSegmentsModal"
/>
<DeleteCustomViews
v-if="showDeleteSegmentsModal"
v-model:show="showDeleteSegmentsModal"
:active-custom-view="activeSegment"
:custom-views-id="segmentsId"
:active-filter-type="filterType"
:open-last-item-after-delete="openLastItemAfterDeleteInSegment"
@close="onCloseDeleteSegmentsModal"
/>
<ContactInfoPanel
v-if="showContactViewPane"
:contact="selectedContact"
:on-close="closeContactInfoPanel"
/>
<CreateContact :show="showCreateModal" @cancel="onToggleCreate" />
<woot-modal v-model:show="showImportModal" :on-close="onToggleImport">
<ImportContacts v-if="showImportModal" :on-close="onToggleImport" />
</woot-modal>
<woot-modal
v-model:show="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<ContactsAdvancedFilters
v-if="showFiltersModal"
:on-close="closeAdvanceFiltersModal"
:initial-filter-types="contactFilterItems"
:initial-applied-filters="appliedFilter"
:active-segment-name="activeSegmentName"
:is-segments-view="hasActiveSegments"
@apply-filter="onApplyFilter"
@update-segment="onUpdateSegment"
@clear-filters="clearFilters"
/>
</woot-modal>
</div>
</template>
@@ -1,230 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
export default {
props: {
headerTitle: {
type: String,
default: '',
},
searchQuery: {
type: String,
default: '',
},
segmentsId: {
type: [String, Number],
default: 0,
},
},
emits: [
'onToggleSaveFilter',
'onToggleEditFilter',
'onToggleDeleteFilter',
'onToggleCreate',
'onToggleFilter',
'onToggleImport',
'onExportSubmit',
'onSearchSubmit',
'onInputSearch',
],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return {
showCreateModal: false,
showImportModal: false,
};
},
computed: {
searchButtonClass() {
return this.searchQuery !== ''
? 'opacity-100 translate-x-0 visible'
: '-translate-x-px opacity-0 invisible';
},
...mapGetters({
getAppliedContactFilters: 'contacts/getAppliedContactFilters',
}),
hasAppliedFilters() {
return this.getAppliedContactFilters.length;
},
hasActiveSegments() {
return this.segmentsId !== 0;
},
exportDescription() {
return this.hasAppliedFilters
? this.$t('EXPORT_CONTACTS.CONFIRM.FILTERED_MESSAGE')
: this.$t('EXPORT_CONTACTS.CONFIRM.MESSAGE');
},
},
methods: {
onToggleSegmentsModal() {
this.$emit('onToggleSaveFilter');
},
onToggleEditSegmentsModal() {
this.$emit('onToggleEditFilter');
},
onToggleDeleteSegmentsModal() {
this.$emit('onToggleDeleteFilter');
},
toggleCreate() {
this.$emit('onToggleCreate');
},
toggleFilter() {
this.$emit('onToggleFilter');
},
toggleImport() {
this.$emit('onToggleImport');
},
async submitExport() {
const ok =
await this.$refs.confirmExportContactsDialog.showConfirmation();
if (ok) {
this.$emit('onExportSubmit');
}
},
submitSearch() {
this.$emit('onSearchSubmit');
},
inputSearch(event) {
this.$emit('onInputSearch', event);
},
},
};
</script>
<template>
<header
class="bg-white border-b dark:bg-slate-900 border-slate-50 dark:border-slate-800"
>
<div class="flex justify-between w-full px-4 py-2">
<div class="flex flex-col items-center w-full gap-2 md:flex-row">
<div class="flex justify-between w-full gap-2">
<div
class="flex items-center justify-center max-w-full min-w-[6.25rem]"
>
<woot-sidemenu-icon />
<h1
class="m-0 mx-2 my-0 overflow-hidden text-xl text-slate-900 dark:text-slate-100 whitespace-nowrap text-ellipsis"
>
{{ headerTitle }}
</h1>
</div>
<div
class="max-w-[400px] min-w-[100px] flex items-center relative mx-2"
>
<div class="flex items-center absolute h-full left-2.5">
<fluent-icon
icon="search"
class="h-5 text-sm leading-9 text-slate-700 dark:text-slate-200"
/>
</div>
<input
type="text"
:placeholder="$t('CONTACTS_PAGE.SEARCH_INPUT_PLACEHOLDER')"
class="!pl-9 !pr-[3.75rem] !text-sm !w-full !h-[2.375rem] !m-0 border-slate-100 dark:border-slate-600"
:value="searchQuery"
@keyup.enter="submitSearch"
@input="inputSearch"
/>
<woot-button
:is-loading="false"
class="absolute h-8 px-2 py-0 ml-2 transition-transform duration-100 ease-linear clear right-1"
:class-names="searchButtonClass"
@click="submitSearch"
>
{{ $t('CONTACTS_PAGE.SEARCH_BUTTON') }}
</woot-button>
</div>
</div>
<div class="flex gap-1">
<div v-if="hasActiveSegments" class="flex gap-2">
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="secondary"
icon="edit"
@click="onToggleEditSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_EDIT') }}
</woot-button>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="alert"
icon="delete"
@click="onToggleDeleteSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_DELETE') }}
</woot-button>
</div>
<div v-if="!hasActiveSegments" class="relative">
<div
v-if="hasAppliedFilters"
class="absolute w-2 h-2 rounded-full top-1 right-3 bg-slate-500 dark:bg-slate-500"
/>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="secondary"
data-testid="create-new-contact"
icon="filter"
@click="toggleFilter"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS') }}
</woot-button>
</div>
<woot-button
v-if="hasAppliedFilters && !hasActiveSegments"
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="alert"
variant="clear"
icon="save"
@click="onToggleSegmentsModal"
>
{{ $t('CONTACTS_PAGE.FILTER_CONTACTS_SAVE') }}
</woot-button>
<woot-button
class="clear [&>span]:hidden xs:[&>span]:block"
color-scheme="success"
icon="person-add"
data-testid="create-new-contact"
@click="toggleCreate"
>
{{ $t('CREATE_CONTACT.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="upload"
class="clear [&>span]:hidden xs:[&>span]:block"
@click="toggleImport"
>
{{ $t('IMPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
<woot-button
v-if="isAdmin"
color-scheme="info"
icon="download"
class="clear [&>span]:hidden xs:[&>span]:block"
@click="submitExport"
>
{{ $t('EXPORT_CONTACTS.BUTTON_LABEL') }}
</woot-button>
</div>
</div>
</div>
<woot-confirm-modal
ref="confirmExportContactsDialog"
:title="$t('EXPORT_CONTACTS.CONFIRM.TITLE')"
:description="exportDescription"
:confirm-label="$t('EXPORT_CONTACTS.CONFIRM.YES')"
:cancel-label="$t('EXPORT_CONTACTS.CONFIRM.NO')"
/>
</header>
</template>
@@ -1,96 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import Modal from '../../../../components/Modal.vue';
import { useTrack } from 'dashboard/composables';
export default {
components: {
Modal,
},
props: {
onClose: {
type: Function,
default: () => {},
},
},
data() {
return {
show: true,
file: '',
};
},
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
csvUrl() {
return '/downloads/import-contacts-sample.csv';
},
},
mounted() {
useTrack(CONTACTS_EVENTS.IMPORT_MODAL_OPEN);
},
methods: {
async uploadFile() {
try {
if (!this.file) return;
await this.$store.dispatch('contacts/import', this.file);
this.onClose();
useAlert(this.$t('IMPORT_CONTACTS.SUCCESS_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_SUCCESS);
} catch (error) {
useAlert(error.message || this.$t('IMPORT_CONTACTS.ERROR_MESSAGE'));
useTrack(CONTACTS_EVENTS.IMPORT_FAILURE);
}
},
handleFileUpload() {
this.file = this.$refs.file.files[0];
},
},
};
</script>
<template>
<Modal v-model:show="show" :on-close="onClose">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="$t('IMPORT_CONTACTS.TITLE')">
<p>
{{ $t('IMPORT_CONTACTS.DESC') }}
<a :href="csvUrl" download="import-contacts-sample">{{
$t('IMPORT_CONTACTS.DOWNLOAD_LABEL')
}}</a>
</p>
</woot-modal-header>
<div class="flex flex-col p-8">
<div class="w-full">
<label>
<span>{{ $t('IMPORT_CONTACTS.FORM.LABEL') }}</span>
<input
id="file"
ref="file"
type="file"
accept="text/csv"
@change="handleFileUpload"
/>
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<div class="w-full">
<woot-button
:disabled="uiFlags.isCreating || !file"
:loading="uiFlags.isCreating"
@click="uploadFile"
>
{{ $t('IMPORT_CONTACTS.FORM.SUBMIT') }}
</woot-button>
<button class="button clear" @click.prevent="onClose">
{{ $t('IMPORT_CONTACTS.FORM.CANCEL') }}
</button>
</div>
</div>
</div>
</div>
</Modal>
</template>
@@ -1,126 +0,0 @@
<script>
export default {
props: {
id: {
type: Number,
default: 0,
},
text: {
type: String,
default: '',
},
isCompleted: {
type: Boolean,
default: false,
},
date: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
},
emits: ['completed', 'edit', 'delete'],
methods: {
onClick() {
this.$emit('completed', this.isCompleted);
},
onEdit() {
this.$emit('edit', this.id);
},
onDelete() {
this.$emit('delete', this.id);
},
},
};
</script>
<template>
<div class="reminder-wrap">
<div class="status-wrap">
<input :checked="isCompleted" type="radio" @click="onClick" />
</div>
<div class="wrap">
<p class="content">
{{ text }}
</p>
<div class="footer">
<div class="meta">
<woot-label
:title="date"
description="date"
icon="ion-android-calendar"
color-scheme="secondary"
/>
<woot-label
:title="label"
description="label"
color-scheme="secondary"
/>
</div>
<div class="actions">
<woot-button
variant="smooth"
size="small"
icon="edit"
color-scheme="secondary"
class="action-button"
@click="onEdit"
/>
<woot-button
variant="smooth"
size="small"
icon="ion-trash-b"
color-scheme="secondary"
class="action-button"
@click="onDelete"
/>
</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.reminder-wrap {
display: flex;
margin-bottom: var(--space-smaller);
.status-wrap {
padding: var(--space-small) var(--space-smaller);
margin-top: var(--space-smaller);
}
.wrap {
padding: var(--space-small);
.footer {
display: flex;
justify-content: space-between;
.meta {
display: flex;
}
}
}
}
.actions {
display: none;
.action-button {
margin-right: var(--space-small);
height: var(--space-medium);
width: var(--space-medium);
}
}
.reminder-wrap:hover {
.actions {
display: flex;
}
}
</style>
@@ -1,73 +0,0 @@
<script>
export default {
emits: ['notes', 'events', 'conversation'],
methods: {
onClickNotes() {
this.$emit('notes');
},
onClickEvents() {
this.$emit('events');
},
onClickConversation() {
this.$emit('conversation');
},
},
};
</script>
<template>
<div class="wrap">
<div class="header">
<h5 class="text-lg text-black-900 dark:text-slate-200">
{{ $t('EVENTS.HEADER.TITLE') }}
</h5>
</div>
<div class="button-wrap">
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickNotes"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_NOTES') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickEvents"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_EVENTS') }}
</woot-button>
<woot-button
variant="hollow"
size="tiny"
color-scheme="secondary"
class-names="pill-button"
@click="onClickConversation"
>
{{ $t('EVENTS.BUTTON.PILL_BUTTON_CONVO') }}
</woot-button>
</div>
</div>
</template>
<style lang="scss" scoped>
.wrap {
width: 100%;
padding: var(--space-normal);
.button-wrap {
display: flex;
.pill-button {
margin-right: var(--space-small);
}
}
}
</style>
@@ -1,133 +0,0 @@
<!-- Unused file deprecated -->
<script>
import { dynamicTime } from 'shared/helpers/timeHelper';
export default {
props: {
eventType: {
type: String,
default: '',
},
eventPath: {
type: String,
default: '',
},
eventBody: {
type: String,
default: '',
},
timeStamp: {
type: Number,
default: 0,
},
},
emits: ['more'],
computed: {
readableTime() {
return dynamicTime(this.timeStamp);
},
},
methods: {
onClick() {
this.$emit('more');
},
},
};
</script>
<template>
<div class="timeline-card-wrap">
<div class="icon-chatbox">
<i class="ion-chatboxes" />
</div>
<div class="card-wrap">
<div class="header">
<div class="text-wrap">
<h6 class="text-sm">
{{ eventType }}
</h6>
<span class="event-path">{{ 'on' }} {{ eventPath }}</span>
</div>
<div class="date-wrap">
<span>{{ readableTime }}</span>
</div>
</div>
<div class="comment-wrap">
<p class="comment">
{{ eventBody }}
</p>
</div>
</div>
<div class="icon-more" @click="onClick">
<i class="ion-android-more-vertical" />
</div>
</div>
</template>
<style lang="scss" scoped>
.timeline-card-wrap {
display: flex;
width: 100%;
color: var(--color-body);
padding: var(--space-small);
.icon-chatbox {
width: var(--space-large);
height: var(--space-large);
border-radius: 50%;
display: flex;
flex-shrink: 0;
justify-content: center;
border: 1px solid var(--color-border);
background-color: var(--color-background);
.ion-chatboxes {
font-size: var(--font-size-default);
display: flex;
align-items: center;
}
}
.card-wrap {
display: flex;
flex-direction: column;
width: 100%;
padding: var(--space-smaller) var(--space-normal) 0;
.header {
display: flex;
justify-content: space-between;
.text-wrap {
display: flex;
}
.event-path {
font-size: var(--font-size-mini);
margin-left: var(--space-smaller);
}
.date-wrap {
font-size: var(--font-size-micro);
}
}
.comment-wrap {
border: 1px solid var(--color-border-light);
.comment {
padding: var(--space-small);
font-size: var(--font-size-mini);
margin: 0;
}
}
}
.icon-more {
.ion-android-more-vertical {
font-size: var(--font-size-medium);
}
}
}
</style>
@@ -1,46 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import AddReminder from '../AddReminder.vue';
let wrapper;
describe('AddReminder', () => {
beforeEach(() => {
wrapper = shallowMount(AddReminder, {
mocks: {
$t: x => x,
$store: { getters: {}, state: {} },
},
stubs: { WootButton: { template: '<button />' } },
});
});
it('tests resetValue', () => {
const resetValue = vi.spyOn(wrapper.vm, 'resetValue');
wrapper.vm.content = 'test';
wrapper.vm.date = '08/11/2022';
wrapper.vm.resetValue();
expect(wrapper.vm.content).toEqual('');
expect(wrapper.vm.date).toEqual('');
expect(resetValue).toHaveBeenCalled();
});
it('tests optionSelected', () => {
const optionSelected = vi.spyOn(wrapper.vm, 'optionSelected');
wrapper.vm.label = '';
wrapper.vm.optionSelected({ target: { value: 'test' } });
expect(wrapper.vm.label).toEqual('test');
expect(optionSelected).toHaveBeenCalled();
});
it('tests onAdd', () => {
const onAdd = vi.spyOn(wrapper.vm, 'onAdd');
wrapper.vm.label = 'label';
wrapper.vm.content = 'content';
wrapper.vm.date = '08/11/2022';
wrapper.vm.onAdd();
expect(onAdd).toHaveBeenCalled();
expect(wrapper.emitted().add[0]).toEqual([
{ content: 'content', date: '08/11/2022', label: 'label' },
]);
});
});
@@ -62,7 +62,11 @@ const goToContactsList = () => {
const fetchActiveContact = async () => {
if (route.params.contactId) {
store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch('contacts/show', { id: route.params.contactId });
await store.dispatch(
'contacts/fetchContactableInbox',
route.params.contactId
);
}
};
@@ -97,7 +101,7 @@ onMounted(() => {
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<ContactsDetailsLayout
:button-label="$t('CONTACTS_LAYOUT.HEADER.MESSAGE_BUTTON')"
:button-label="$t('CONTACTS_LAYOUT.HEADER.SEND_MESSAGE')"
:selected-contact="selectedContact"
is-detail-view
:show-pagination-footer="false"
@@ -141,6 +145,7 @@ onMounted(() => {
ref="contactMergeRef"
:selected-contact="selectedContact"
@go-to-contacts-list="goToContactsList"
@reset-tab="handleTabChange(CONTACT_TABS_OPTIONS[0])"
/>
</template>
</template>

Some files were not shown because too many files have changed in this diff Show More