Merge branch 'develop' into feat/chat-list-header

This commit is contained in:
Sivin Varghese
2024-12-06 09:05:09 +05:30
committed by GitHub
53 changed files with 3172 additions and 444 deletions
@@ -50,7 +50,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"
@@ -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')"
/>
@@ -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') }}
@@ -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">
@@ -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,260 @@
<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 useFileUpload = () => {
// Empty function for testing purposes
// TODO: Will use useFileUpload composable later
return {
onFileUpload: () => {},
};
};
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,332 @@
<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 => {
emit('searchContacts', 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', value);
};
const searchBccEmails = value => {
showBccEmailsDropdown.value = true;
emit('searchContacts', 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="absolute right-0 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,141 @@
<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 { t } = useI18n();
const contactsList = computed(() => {
return props.contacts?.map(({ name, id, thumbnail, email, ...rest }) => ({
id,
label: `${name} (${email})`,
value: id,
thumbnail: { name, src: thumbnail },
...rest,
name,
email,
action: 'contact',
}));
});
const selectedContactLabel = computed(() => {
return `${props.selectedContact?.name} (${props.selectedContact?.email})`;
});
</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('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.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('COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.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(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.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(
'COMPOSE_NEW_CONVERSATION.FORM.CONTACT_SELECTOR.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="
hasErrors
? '[&_input]:placeholder:!text-n-ruby-9 [&_input]:dark:placeholder:!text-n-ruby-9'
: ''
"
@focus="emit('updateDropdown', 'contacts', true)"
@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 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,118 @@
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const convertChannelTypeToLabel = channelType => {
const [, type] = channelType.split('::');
return type ? type.charAt(0).toUpperCase() + type.slice(1) : channelType;
};
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} (${convertChannelTypeToLabel(channelType)})`;
};
export const buildContactableInboxesList = contactInboxes => {
if (!contactInboxes) return [];
return contactInboxes.map(
({ name, id, email, channelType, phoneNumber, ...rest }) => ({
id,
label: generateLabelForContactableInboxesList({
name,
email,
channelType,
phoneNumber,
}),
action: 'inbox',
value: id,
name,
email,
phoneNumber,
channelType,
...rest,
})
);
};
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,
};
};
@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import * as helpers from '../composeConversationHelper';
describe('composeConversationHelper', () => {
describe('convertChannelTypeToLabel', () => {
it('converts channel type with namespace to capitalized label', () => {
expect(helpers.convertChannelTypeToLabel('Channel::Email')).toBe('Email');
expect(helpers.convertChannelTypeToLabel('Channel::Whatsapp')).toBe(
'Whatsapp'
);
});
it('returns original value if no namespace found', () => {
expect(helpers.convertChannelTypeToLabel('email')).toBe('email');
});
});
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 (Api)');
});
});
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,
label: 'Email Inbox (support@example.com)',
action: 'inbox',
value: 1,
name: 'Email Inbox',
email: 'support@example.com',
channelType: INBOX_TYPES.EMAIL,
});
});
});
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,
});
});
});
});
@@ -25,6 +25,10 @@ const props = defineProps({
type: String,
default: '',
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['action']);
@@ -81,7 +85,6 @@ onMounted(() => {
}"
:disabled="item.disabled"
@click="handleAction(item)"
@keydown.enter="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
@@ -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>
@@ -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"
@@ -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>
@@ -1,51 +1,168 @@
<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)
);
// Only show typed value as suggestion if:
// 1. There's a value being typed
// 2. The value isn't already in the tags
// 3. There are no menu items available
const trimmedNewTag = newTag.value.trim();
if (
trimmedNewTag &&
!tags.value.includes(trimmedNewTag) &&
!availableMenuItems.length &&
!props.isLoading
) {
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 +170,76 @@ 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 class="relative flex items-center gap-2 flex-1 min-w-[200px] w-full">
<InlineInput
v-if="showInput"
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"
@@ -26,7 +26,13 @@ export default {
emitter.off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message, action }) {
onNewToastMessage({ message: originalMessage, action }) {
// FIX ME: This is a temporary workaround to pass string from functions
// that doesn't have the context of the VueApp.
const usei18n = action?.usei18n;
const duration = action?.duration || this.duration;
const message = usei18n ? this.$t(originalMessage) : originalMessage;
this.snackMessages.push({
key: new Date().getTime(),
message,
@@ -34,7 +40,7 @@ export default {
});
window.setTimeout(() => {
this.snackMessages.splice(0, 1);
}, this.duration);
}, duration);
},
},
};
@@ -62,10 +62,13 @@ export default {
},
},
watch: {
'currentChat.inbox_id'(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
'currentChat.inbox_id': {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
}
},
},
'currentChat.id'() {
this.fetchLabels();
@@ -0,0 +1,5 @@
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
@@ -0,0 +1,6 @@
export const getAssignee = message => message?.conversation?.assignee_id;
export const isConversationUnassigned = message => !getAssignee(message);
export const isConversationAssignedToMe = (message, currentUserId) =>
getAssignee(message) === currentUserId;
export const isMessageFromCurrentUser = (message, currentUserId) =>
message?.sender?.id === currentUserId;
@@ -0,0 +1,37 @@
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
class AudioNotificationStore {
constructor(store) {
this.store = store;
}
hasUnreadConversation = () => {
const mineConversation = this.store.getters.getMineChats({
assigneeType: 'me',
status: 'open',
});
return mineConversation.some(conv => conv.unread_count > 0);
};
isMessageFromCurrentConversation = message => {
return this.store.getters.getSelectedChat?.id === message.conversation_id;
};
hasConversationPermission = user => {
const currentAccountId = this.store.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(user, currentAccountId);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
};
}
export default AudioNotificationStore;
@@ -1,92 +1,125 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { showBadgeOnFavicon } from './faviconHelper';
import { initFaviconSwitcher } from './faviconHelper';
import { EVENT_TYPES } from 'dashboard/routes/dashboard/settings/profile/constants.js';
import GlobalStore from 'dashboard/store';
import AudioNotificationStore from './AudioNotificationStore';
import {
getAlertAudio,
initOnEvents,
} from 'shared/helpers/AudioNotificationHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js';
isConversationAssignedToMe,
isConversationUnassigned,
isMessageFromCurrentUser,
} from './AudioMessageHelper';
import WindowVisibilityHelper from './WindowVisibilityHelper';
import { useAlert } from 'dashboard/composables';
const NOTIFICATION_TIME = 30000;
const ALERT_DURATION = 10000;
const ALERT_PATH_PREFIX = '/audio/dashboard/';
const DEFAULT_TONE = 'ding';
const DEFAULT_ALERT_TYPE = ['none'];
class DashboardAudioNotificationHelper {
constructor() {
this.recurringNotificationTimer = null;
this.audioAlertType = 'none';
this.playAlertOnlyWhenHidden = true;
this.alertIfUnreadConversationExist = false;
this.currentUser = null;
this.currentUserId = null;
this.audioAlertTone = 'ding';
export class DashboardAudioNotificationHelper {
constructor(store) {
if (!store) {
throw new Error('store is required');
}
this.store = new AudioNotificationStore(store);
this.onAudioListenEvent = async () => {
try {
await getAlertAudio('', {
type: 'dashboard',
alertTone: this.audioAlertTone,
});
initOnEvents.forEach(event => {
document.removeEventListener(event, this.onAudioListenEvent, false);
});
this.playAudioEvery30Seconds();
} catch (error) {
// Ignore audio fetch errors
}
this.notificationConfig = {
audioAlertType: DEFAULT_ALERT_TYPE,
playAlertOnlyWhenHidden: true,
alertIfUnreadConversationExist: false,
};
this.recurringNotificationTimer = null;
this.audioConfig = {
audio: null,
tone: DEFAULT_TONE,
hasSentSoundPermissionsRequest: false,
};
this.currentUser = null;
}
setInstanceValues = ({
intializeAudio = () => {
const resourceUrl = `${ALERT_PATH_PREFIX}${this.audioConfig.tone}.mp3`;
this.audioConfig.audio = new Audio(resourceUrl);
return this.audioConfig.audio.load();
};
playAudioAlert = async () => {
try {
await this.audioConfig.audio.play();
} catch (error) {
if (
error.name === 'NotAllowedError' &&
!this.hasSentSoundPermissionsRequest
) {
this.hasSentSoundPermissionsRequest = true;
useAlert(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.SOUND_PERMISSION_ERROR',
{ usei18n: true, duration: ALERT_DURATION }
);
}
}
};
set = ({
currentUser,
alwaysPlayAudioAlert,
alertIfUnreadConversationExist,
audioAlertType,
audioAlertTone,
audioAlertType = DEFAULT_ALERT_TYPE,
audioAlertTone = DEFAULT_TONE,
}) => {
this.audioAlertType = audioAlertType;
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.notificationConfig = {
...this.notificationConfig,
audioAlertType: audioAlertType.split('+').filter(Boolean),
playAlertOnlyWhenHidden: !alwaysPlayAudioAlert,
alertIfUnreadConversationExist: alertIfUnreadConversationExist,
};
this.currentUser = currentUser;
this.currentUserId = currentUser.id;
this.audioAlertTone = audioAlertTone;
initOnEvents.forEach(e => {
document.addEventListener(e, this.onAudioListenEvent, {
once: true,
});
});
const previousAudioTone = this.audioConfig.tone;
this.audioConfig = {
...this.audioConfig,
tone: audioAlertTone,
};
if (previousAudioTone !== audioAlertTone) {
this.intializeAudio();
}
initFaviconSwitcher();
this.clearRecurringTimer();
this.playAudioEvery30Seconds();
};
shouldPlayAlert = () => {
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return !WindowVisibilityHelper.isWindowVisible();
}
return true;
};
executeRecurringNotification = () => {
if (!window.WOOT_STORE) {
this.clearSetTimeout();
return;
}
const mineConversation = window.WOOT_STORE.getters.getMineChats({
assigneeType: 'me',
status: 'open',
});
const hasUnreadConversation = mineConversation.some(conv => {
return conv.unread_count > 0;
});
const shouldPlayAlert = !this.playAlertOnlyWhenHidden || document.hidden;
if (hasUnreadConversation && shouldPlayAlert) {
window.playAudioAlert();
if (this.store.hasUnreadConversation() && this.shouldPlayAlert()) {
this.playAudioAlert();
showBadgeOnFavicon();
}
this.clearSetTimeout();
this.resetRecurringTimer();
};
clearSetTimeout = () => {
clearRecurringTimer = () => {
if (this.recurringNotificationTimer) {
clearTimeout(this.recurringNotificationTimer);
}
};
resetRecurringTimer = () => {
this.clearRecurringTimer();
this.recurringNotificationTimer = setTimeout(
this.executeRecurringNotification,
NOTIFICATION_TIME
@@ -94,67 +127,57 @@ class DashboardAudioNotificationHelper {
};
playAudioEvery30Seconds = () => {
const { audioAlertType, alertIfUnreadConversationExist } =
this.notificationConfig;
// Audio alert is disabled dismiss the timer
if (this.audioAlertType === 'none') {
return;
}
// If assigned conversation flag is disabled dismiss the timer
if (!this.alertIfUnreadConversationExist) {
return;
}
if (audioAlertType.includes('none')) return;
this.clearSetTimeout();
};
// If unread conversation flag is disabled, dismiss the timer
if (!alertIfUnreadConversationExist) return;
isConversationAssignedToCurrentUser = message => {
const conversationAssigneeId = message?.conversation?.assignee_id;
return conversationAssigneeId === this.currentUserId;
};
// eslint-disable-next-line class-methods-use-this
isMessageFromCurrentConversation = message => {
return (
window.WOOT_STORE.getters.getSelectedChat?.id === message.conversation_id
);
};
isMessageFromCurrentUser = message => {
return message?.sender_id === this.currentUserId;
};
isUserHasConversationPermission = () => {
const currentAccountId = window.WOOT_STORE.getters.getCurrentAccountId;
// Get the user permissions for the current account
const userPermissions = getUserPermissions(
this.currentUser,
currentAccountId
);
// Check if the user has the required permissions
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
permission => userPermissions.includes(permission)
);
return hasRequiredPermission;
this.resetRecurringTimer();
};
shouldNotifyOnMessage = message => {
if (this.audioAlertType === 'mine') {
return this.isConversationAssignedToCurrentUser(message);
const { audioAlertType } = this.notificationConfig;
if (audioAlertType.includes('none')) return false;
if (audioAlertType.includes('all')) return true;
const assignedToMe = isConversationAssignedToMe(
message,
this.currentUser.id
);
const isUnassigned = isConversationUnassigned(message);
const shouldPlayAudio = [];
if (audioAlertType.includes(EVENT_TYPES.ASSIGNED)) {
shouldPlayAudio.push(assignedToMe);
}
return this.audioAlertType === 'all';
if (audioAlertType.includes(EVENT_TYPES.UNASSIGNED)) {
shouldPlayAudio.push(isUnassigned);
}
if (audioAlertType.includes(EVENT_TYPES.NOTME)) {
shouldPlayAudio.push(!isUnassigned && !assignedToMe);
}
return shouldPlayAudio.some(Boolean);
};
onNewMessage = message => {
// If the user does not have the permission to view the conversation, then dismiss the alert
if (!this.isUserHasConversationPermission()) {
// FIX ME: There shouldn't be a new message if the user has no access to the conversation.
if (!this.store.hasConversationPermission(this.currentUser)) {
return;
}
// If the message is sent by the current user or the
// correct notification is not enabled, then dismiss the alert
if (
this.isMessageFromCurrentUser(message) ||
!this.shouldNotifyOnMessage(message)
) {
// If the message is sent by the current user then dismiss the alert
if (isMessageFromCurrentUser(message, this.currentUser.id)) {
return;
}
if (!this.shouldNotifyOnMessage(message)) {
return;
}
@@ -164,21 +187,22 @@ class DashboardAudioNotificationHelper {
return;
}
// If the user looking at the conversation, then dismiss the alert
if (this.isMessageFromCurrentConversation(message) && !document.hidden) {
return;
}
// If the user has disabled alerts when active on the dashboard, the dismiss the alert
if (this.playAlertOnlyWhenHidden && !document.hidden) {
return;
if (WindowVisibilityHelper.isWindowVisible()) {
// If the user looking at the conversation, then dismiss the alert
if (this.store.isMessageFromCurrentConversation(message)) {
return;
}
// If the user has disabled alerts when active on the dashboard, the dismiss the alert
if (this.notificationConfig.playAlertOnlyWhenHidden) {
return;
}
}
window.playAudioAlert();
this.playAudioAlert();
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
}
const notifHelper = new DashboardAudioNotificationHelper();
window.notifHelper = notifHelper;
export default notifHelper;
export default new DashboardAudioNotificationHelper(GlobalStore);
@@ -0,0 +1,21 @@
export class WindowVisibilityHelper {
constructor() {
this.isVisible = true;
this.initializeEvent();
}
initializeEvent = () => {
window.addEventListener('blur', () => {
this.isVisible = false;
});
window.addEventListener('focus', () => {
this.isVisible = true;
});
};
isWindowVisible() {
return !document.hidden && this.isVisible;
}
}
export default new WindowVisibilityHelper();
@@ -0,0 +1,79 @@
import {
getAssignee,
isConversationUnassigned,
isConversationAssignedToMe,
isMessageFromCurrentUser,
} from '../AudioMessageHelper';
describe('getAssignee', () => {
it('should return assignee_id when present', () => {
const message = { conversation: { assignee_id: 1 } };
expect(getAssignee(message)).toBe(1);
});
it('should return undefined when no assignee_id', () => {
const message = { conversation: null };
expect(getAssignee(message)).toBeUndefined();
});
it('should handle null message', () => {
expect(getAssignee(null)).toBeUndefined();
});
});
describe('isConversationUnassigned', () => {
it('should return true when no assignee', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationUnassigned(message)).toBe(true);
});
it('should return false when has assignee', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationUnassigned(message)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationUnassigned(null)).toBe(true);
});
});
describe('isConversationAssignedToMe', () => {
const currentUserId = 1;
it('should return true when assigned to current user', () => {
const message = { conversation: { assignee_id: 1 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(true);
});
it('should return false when assigned to different user', () => {
const message = { conversation: { assignee_id: 2 } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should return false when unassigned', () => {
const message = { conversation: { assignee_id: null } };
expect(isConversationAssignedToMe(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isConversationAssignedToMe(null, currentUserId)).toBe(false);
});
});
describe('isMessageFromCurrentUser', () => {
const currentUserId = 1;
it('should return true when message is from current user', () => {
const message = { sender: { id: 1 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(true);
});
it('should return false when message is from different user', () => {
const message = { sender: { id: 2 } };
expect(isMessageFromCurrentUser(message, currentUserId)).toBe(false);
});
it('should handle null message', () => {
expect(isMessageFromCurrentUser(null, currentUserId)).toBe(false);
});
});
@@ -0,0 +1,131 @@
import AudioNotificationStore from '../AudioNotificationStore';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
describe('AudioNotificationStore', () => {
let store;
let audioNotificationStore;
beforeEach(() => {
store = {
getters: {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
},
};
audioNotificationStore = new AudioNotificationStore(store);
});
describe('hasUnreadConversation', () => {
it('should return true when there are unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 2 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(true);
});
it('should return false when there are no unread conversations', () => {
store.getters.getMineChats.mockReturnValue([
{ id: 1, unread_count: 0 },
{ id: 2, unread_count: 0 },
]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should return false when there are no conversations', () => {
store.getters.getMineChats.mockReturnValue([]);
expect(audioNotificationStore.hasUnreadConversation()).toBe(false);
});
it('should call getMineChats with correct parameters', () => {
store.getters.getMineChats.mockReturnValue([]);
audioNotificationStore.hasUnreadConversation();
expect(store.getters.getMineChats).toHaveBeenCalledWith({
assigneeType: 'me',
status: 'open',
});
});
});
describe('isMessageFromCurrentConversation', () => {
it('should return true when message is from selected chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(true);
});
it('should return false when message is from different chat', () => {
store.getters.getSelectedChat = { id: 6179 };
const message = { conversation_id: 1337 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
it('should return false when no chat is selected', () => {
store.getters.getSelectedChat = null;
const message = { conversation_id: 6179 };
expect(
audioNotificationStore.isMessageFromCurrentConversation(message)
).toBe(false);
});
});
describe('hasConversationPermission', () => {
const mockUser = { id: 'user123' };
beforeEach(() => {
getUserPermissions.mockReset();
});
it('should return true when user has a required role', () => {
getUserPermissions.mockReturnValue([ROLES[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
expect(getUserPermissions).toHaveBeenCalledWith(mockUser, 1);
});
it('should return true when user has a conversation permission', () => {
getUserPermissions.mockReturnValue([CONVERSATION_PERMISSIONS[0]]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
true
);
});
it('should return false when user has no required permissions', () => {
getUserPermissions.mockReturnValue(['some-other-permission']);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
it('should return false when user has no permissions', () => {
getUserPermissions.mockReturnValue([]);
expect(audioNotificationStore.hasConversationPermission(mockUser)).toBe(
false
);
});
});
});
@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { WindowVisibilityHelper } from '../WindowVisibilityHelper';
describe('WindowVisibilityHelper', () => {
let blurCallback;
let focusCallback;
let windowEventListeners;
let documentHiddenValue = false;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
// Reset event listeners before each test
windowEventListeners = {};
// Mock window.addEventListener
window.addEventListener = vi.fn((event, callback) => {
windowEventListeners[event] = callback;
if (event === 'blur') blurCallback = callback;
if (event === 'focus') focusCallback = callback;
});
// Mock document.hidden with a getter that returns our controlled value
Object.defineProperty(document, 'hidden', {
configurable: true,
get: () => documentHiddenValue,
});
});
afterEach(() => {
vi.clearAllMocks();
documentHiddenValue = false;
});
describe('initialization', () => {
it('should add blur and focus event listeners', () => {
const helper = new WindowVisibilityHelper();
expect(helper.isVisible).toBe(true);
expect(window.addEventListener).toHaveBeenCalledTimes(2);
expect(window.addEventListener).toHaveBeenCalledWith(
'blur',
expect.any(Function)
);
expect(window.addEventListener).toHaveBeenCalledWith(
'focus',
expect.any(Function)
);
});
});
describe('window events', () => {
it('should set isVisible to false on blur', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
});
it('should set isVisible to true on focus', () => {
const helper = new WindowVisibilityHelper();
blurCallback(); // First blur the window
focusCallback(); // Then focus it
expect(helper.isVisible).toBe(true);
});
it('should handle multiple blur/focus events', () => {
const helper = new WindowVisibilityHelper();
blurCallback();
expect(helper.isVisible).toBe(false);
focusCallback();
expect(helper.isVisible).toBe(true);
blurCallback();
expect(helper.isVisible).toBe(false);
});
});
describe('isWindowVisible', () => {
it('should return true when document is visible and window is focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(true);
});
it('should return false when document is hidden', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = true;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = false;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
it('should return false when both document is hidden and window is not focused', () => {
const helper = new WindowVisibilityHelper();
documentHiddenValue = true;
helper.isVisible = false;
expect(helper.isWindowVisible()).toBe(false);
});
});
});
@@ -1,20 +1,19 @@
import {
ANALYTICS_IDENTITY,
CHATWOOT_RESET,
CHATWOOT_SET_USER,
} from '../constants/appEvents';
import AnalyticsHelper from './AnalyticsHelper';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => {
emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
});
};
const initializeAudioAlerts = user => {
export const initializeAudioAlerts = user => {
const { ui_settings: uiSettings } = user || {};
const {
always_play_audio_alert: alwaysPlayAudioAlert,
@@ -25,7 +24,7 @@ const initializeAudioAlerts = user => {
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.setInstanceValues({
DashboardAudioNotificationHelper.set({
currentUser: user,
audioAlertType: audioAlertType || 'none',
audioAlertTone: audioAlertTone || 'ding',
@@ -653,5 +653,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": "Type an email address to search for the contact and press Enter",
"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": "Type an email address to search for the contact and press Enter",
"BCC_LABEL": "Bcc:",
"BCC_PLACEHOLDER": "Type an email address to search for the contact and press Enter",
"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})"
}
}
}
}
@@ -61,15 +61,29 @@
"COPY": "Copy"
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Notifications",
"NOTE": "Enable audio notifications in dashboard for new messages and conversations.",
"TITLE": "Audio Alerts",
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
"PLAY": "Play sound",
"ALERT_TYPES": {
"NONE": "None",
"MINE": "Assigned",
"ALL": "All"
"ALL": "All",
"ASSIGNED": "My assigned conversations",
"UNASSIGNED": "Unassigned conversations",
"NOTME": "Open conversations assigned to others"
},
"ALERT_COMBINATIONS": {
"NONE": "You haven't selected any options, you won't receive any audio alerts.",
"ASSIGNED": "You'll receive alerts for conversations assigned to you.",
"UNASSIGNED": "You'll receive alerts for any unassigned conversations.",
"NOTME": "You'll receive alerts for conversations assigned to others.",
"ASSIGNED+UNASSIGNED": "You'll receive alerts for your assigned conversations and any unattended ones.",
"ASSIGNED+NOTME": "You'll receive alerts for conversations assigned to you and to others, but not for unassigned ones.",
"NOTME+UNASSIGNED": "You'll receive alerts for unattended conversations and those assigned to others.",
"ASSIGNED+NOTME+UNASSIGNED": "You'll receive alerts for all conversations."
},
"ALERT_TYPE": {
"TITLE": "Alert events for conversations:",
"TITLE": "Alert events for conversations",
"NONE": "None",
"ASSIGNED": "Assigned Conversations",
"ALL_CONVERSATIONS": "All Conversations"
@@ -81,7 +95,9 @@
"TITLE": "Alert conditions:",
"CONDITION_ONE": "Send audio alerts only if the browser window is not active",
"CONDITION_TWO": "Send alerts every 30s until all the assigned conversations are read"
}
},
"SOUND_PERMISSION_ERROR": "Autoplay is disabled in your browser. To hear alerts automatically, enable sound permission in your browser settings or interact with the page.",
"READ_MORE": "Read more"
},
"EMAIL_NOTIFICATIONS_SECTION": {
"TITLE": "Email Notifications",
@@ -256,6 +256,7 @@ onMounted(async () => {
:active-ordering="sortState.activeOrdering"
:active-segment="activeSegment"
:segments-id="activeSegmentId"
:is-fetching-list="isFetchingList"
:has-applied-filters="hasAppliedFilters"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@@ -10,6 +10,7 @@ defineProps({
required: true,
},
});
const emit = defineEmits(['change']);
const onChange = (id, value) => {
emit('change', id, value);
@@ -23,18 +24,22 @@ const onChange = (id, value) => {
>
{{ label }}
</label>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 mt-2">
<div
v-for="item in items"
:key="item.id"
class="flex flex-row items-start gap-2"
>
<CheckBox
:id="`checkbox-condition-${item.value}`"
:is-checked="item.model"
:value="item.value"
@update="onChange"
/>
<label class="text-sm font-normal text-ash-900">
<label
class="text-sm font-normal text-ash-900"
:for="`checkbox-condition-${item.value}`"
>
{{ item.label }}
</label>
</div>
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import { ALERT_EVENTS } from './constants';
import CheckBox from 'v3/components/Form/CheckBox.vue';
import { ALERT_EVENTS, EVENT_TYPES } from './constants';
const props = defineProps({
label: {
@@ -9,50 +10,83 @@ const props = defineProps({
},
value: {
type: String,
default: 'all',
default: '',
},
});
const emit = defineEmits(['update']);
const alertEvents = ALERT_EVENTS;
const alertEventValues = Object.values(EVENT_TYPES);
const selectedValue = computed({
get: () => props.value,
set: value => {
emit('update', value);
get: () => {
// maintain backward compatibility
if (props.value === 'none') return [];
if (props.value === 'mine') return [EVENT_TYPES.ASSIGNED];
if (props.value === 'all') return [...alertEventValues];
const validValues = props.value
.split('+')
.filter(value => alertEventValues.includes(value));
return [...new Set(validValues)];
},
set: value => {
const sortedValues = value.filter(Boolean).sort();
const uniqueValues = [...new Set(sortedValues)];
if (uniqueValues.length === 0) {
emit('update', 'none');
return;
}
emit('update', uniqueValues.join('+'));
},
});
const setValue = (isChecked, value) => {
let updatedValue = selectedValue.value;
if (isChecked) {
updatedValue.push(value);
} else {
updatedValue = updatedValue.filter(item => item !== value);
}
selectedValue.value = updatedValue;
};
const alertDescription = computed(() => {
const base =
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_COMBINATIONS.';
if (props.value === '' || props.value === 'none') {
return base + 'NONE';
}
return base + selectedValue.value.join('+').toUpperCase();
});
</script>
<template>
<div>
<label
class="flex justify-between pb-1 text-sm font-medium leading-6 text-ash-900"
>
<label class="pb-1 text-sm font-medium leading-6 text-ash-900">
{{ label }}
</label>
<div
class="flex flex-row justify-between h-10 max-w-xl p-2 border border-solid rounded-xl border-ash-200"
>
<div class="grid gap-3 mt-2">
<div
v-for="option in alertEvents"
:key="option.value"
class="flex flex-row items-center justify-center gap-2 px-4 border-r border-ash-200 grow last:border-r-0"
class="flex items-center gap-2"
>
<input
:id="`radio-${option.value}`"
v-model="selectedValue"
class="shadow-sm cursor-pointer grid place-items-center border-2 border-ash-200 appearance-none rounded-full w-4 h-4 checked:bg-primary-600 before:content-[''] before:bg-primary-600 before:border-4 before:rounded-full before:border-ash-25 checked:before:w-[14px] checked:before:h-[14px] checked:border checked:border-primary-600"
type="radio"
:value="option.value"
<CheckBox
:id="`checkbox-${option.value}`"
:is-checked="selectedValue.includes(option.value)"
@update="(_val, isChecked) => setValue(isChecked, option.value)"
/>
<label
:for="`radio-${option.value}`"
class="text-sm font-medium"
:class="
selectedValue === option.value ? 'text-ash-900' : 'text-ash-800'
"
:for="`checkbox-${option.value}`"
class="text-sm text-ash-900 font-normal"
>
{{
$t(
@@ -61,6 +95,9 @@ const selectedValue = computed({
}}
</label>
</div>
<div class="text-n-slate-11 text-sm font-medium mt-2">
{{ $t(alertDescription) }}
</div>
</div>
</div>
</template>
@@ -1,12 +1,15 @@
<script setup>
import { computed } from 'vue';
import Icon from 'next/icon/Icon.vue';
import * as Sentry from '@sentry/vue';
import FormSelect from 'v3/components/Form/Select.vue';
const props = defineProps({
value: {
type: String,
required: true,
validator: value => ['ding', 'bell'].includes(value),
validator: value =>
['ding', 'bell', 'chime', 'magic', 'ping'].includes(value),
},
label: {
type: String,
@@ -25,6 +28,18 @@ const alertTones = computed(() => [
value: 'bell',
label: 'Bell',
},
{
value: 'chime',
label: 'Chime',
},
{
value: 'magic',
label: 'Magic',
},
{
value: 'ping',
label: 'Ping',
},
]);
const selectedValue = computed({
@@ -33,25 +48,48 @@ const selectedValue = computed({
emit('change', value);
},
});
const audio = new Audio();
const playAudio = async () => {
try {
// Has great support https://caniuse.com/mdn-api_htmlaudioelement
audio.src = `/audio/dashboard/${selectedValue.value}.mp3`;
await audio.play();
} catch (error) {
Sentry.captureException(error);
}
};
</script>
<template>
<FormSelect
v-model="selectedValue"
name="alertTone"
spacing="compact"
:value="selectedValue"
:options="alertTones"
:label="label"
class="max-w-xl"
>
<option
v-for="tone in alertTones"
:key="tone.label"
:value="tone.value"
:selected="tone.value === selectedValue"
<div class="flex items-center gap-2">
<FormSelect
v-model="selectedValue"
name="alertTone"
spacing="compact"
class="flex-grow"
:value="selectedValue"
:options="alertTones"
:label="label"
>
{{ tone.label }}
</option>
</FormSelect>
<option
v-for="tone in alertTones"
:key="tone.label"
:value="tone.value"
:selected="tone.value === selectedValue"
>
{{ tone.label }}
</option>
</FormSelect>
<button
v-tooltip.top="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.PLAY')
"
class="border-0 shadow-sm outline-none flex justify-center items-center size-10 appearance-none rounded-xl ring-ash-200 ring-1 ring-inset focus:ring-2 focus:ring-inset focus:ring-primary-500 flex-shrink-0 mt-[28px]"
@click="playAudio"
>
<Icon icon="i-lucide-volume-2" />
</button>
</div>
</template>
@@ -1,98 +1,91 @@
<script>
<script setup>
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import AudioAlertTone from './AudioAlertTone.vue';
import AudioAlertEvent from './AudioAlertEvent.vue';
import AudioAlertCondition from './AudioAlertCondition.vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'dashboard/composables/store';
const store = useStore();
import { useI18n } from 'vue-i18n';
import camelcaseKeys from 'camelcase-keys';
import { initializeAudioAlerts } from 'dashboard/helper/scriptHelpers';
import { useStoreGetters } from 'dashboard/composables/store';
export default {
components: {
AudioAlertEvent,
AudioAlertTone,
AudioAlertCondition,
},
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const getters = useStoreGetters();
const currentUser = computed(() => getters.getCurrentUser.value);
return {
uiSettings,
updateUISettings,
};
},
data() {
return {
audioAlert: '',
playAudioWhenTabIsInactive: false,
alertIfUnreadConversationExist: false,
alertTone: 'ding',
audioAlertConditions: [],
};
},
watch: {
uiSettings(value) {
this.notificationUISettings(value);
},
},
mounted() {
this.notificationUISettings(this.uiSettings);
this.$store.dispatch('userNotificationSettings/get');
},
methods: {
notificationUISettings(uiSettings) {
const {
enable_audio_alerts: audioAlert = '',
always_play_audio_alert: alwaysPlayAudioAlert,
alert_if_unread_assigned_conversation_exist:
alertIfUnreadConversationExist,
notification_tone: alertTone,
} = uiSettings;
this.audioAlert = audioAlert;
this.playAudioWhenTabIsInactive = !alwaysPlayAudioAlert;
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
this.audioAlertConditions = [
{
id: 'audio1',
label: this.$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_ONE'
),
model: this.playAudioWhenTabIsInactive,
value: 'tab_is_inactive',
},
{
id: 'audio2',
label: this.$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.CONDITION_TWO'
),
model: this.alertIfUnreadConversationExist,
value: 'conversations_are_read',
},
];
this.alertTone = alertTone || 'ding';
},
handAudioAlertChange(value) {
this.audioAlert = value;
this.updateUISettings({
enable_audio_alerts: this.audioAlert,
});
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioAlertConditions(id, value) {
if (id === 'tab_is_inactive') {
this.updateUISettings({
always_play_audio_alert: !value,
});
} else if (id === 'conversations_are_read') {
this.updateUISettings({
alert_if_unread_assigned_conversation_exist: value,
});
}
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
},
handleAudioToneChange(value) {
this.updateUISettings({ notification_tone: value });
useAlert(this.$t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
const { uiSettings, updateUISettings } = useUISettings();
const { t } = useI18n();
const audioAlert = ref('');
const playAudioWhenTabIsInactive = ref(false);
const alertIfUnreadConversationExist = ref(false);
const alertTone = ref('ding');
const audioAlertConditions = ref([]);
const i18nKeyPrefix = 'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION';
const initializeNotificationUISettings = newUISettings => {
const updatedUISettings = camelcaseKeys(newUISettings);
audioAlert.value = updatedUISettings.enableAudioAlerts;
playAudioWhenTabIsInactive.value = !updatedUISettings.alwaysPlayAudioAlert;
alertIfUnreadConversationExist.value =
updatedUISettings.alertIfUnreadAssignedConversationExist;
audioAlertConditions.value = [
{
id: 'audio1',
label: t(`${i18nKeyPrefix}.CONDITIONS.CONDITION_ONE`),
model: playAudioWhenTabIsInactive.value,
value: 'tab_is_inactive',
},
{
id: 'audio2',
label: t(`${i18nKeyPrefix}.CONDITIONS.CONDITION_TWO`),
model: alertIfUnreadConversationExist.value,
value: 'conversations_are_read',
},
];
alertTone.value = updatedUISettings.notificationTone || 'ding';
};
watch(
uiSettings,
value => {
initializeNotificationUISettings(value);
},
{ immediate: true }
);
const handleAudioConfigChange = value => {
updateUISettings(value);
initializeAudioAlerts(currentUser.value);
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
};
onMounted(() => {
store.dispatch('userNotificationSettings/get');
});
const handAudioAlertChange = value => {
audioAlert.value = value;
handleAudioConfigChange({
enable_audio_alerts: value,
});
};
const handleAudioAlertConditions = (id, value) => {
if (id === 'tab_is_inactive') {
handleAudioConfigChange({
always_play_audio_alert: !value,
});
} else if (id === 'conversations_are_read') {
handleAudioConfigChange({
alert_if_unread_assigned_conversation_exist: value,
});
}
};
const handleAudioToneChange = value => {
handleAudioConfigChange({ notification_tone: value });
};
</script>
@@ -100,27 +93,19 @@ export default {
<div id="profile-settings-notifications" class="flex flex-col gap-6">
<AudioAlertTone
:value="alertTone"
:label="
$t(
'PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.DEFAULT_TONE.TITLE'
)
"
:label="$t(`${i18nKeyPrefix}.DEFAULT_TONE.TITLE`)"
@change="handleAudioToneChange"
/>
<AudioAlertEvent
:label="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.ALERT_TYPE.TITLE')
"
:label="$t(`${i18nKeyPrefix}.ALERT_TYPE.TITLE`)"
:value="audioAlert"
@update="handAudioAlertChange"
/>
<AudioAlertCondition
:items="audioAlertConditions"
:label="
$t('PROFILE_SETTINGS.FORM.AUDIO_NOTIFICATIONS_SECTION.CONDITIONS.TITLE')
"
:label="$t(`${i18nKeyPrefix}.CONDITIONS.TITLE`)"
@change="handleAudioAlertConditions"
/>
</div>
@@ -36,17 +36,23 @@ export const NOTIFICATION_TYPES = [
},
];
export const EVENT_TYPES = {
ASSIGNED: 'assigned',
NOTME: 'notme',
UNASSIGNED: 'unassigned',
};
export const ALERT_EVENTS = [
{
value: 'none',
label: 'none',
value: EVENT_TYPES.ASSIGNED,
label: 'assigned',
},
{
value: 'mine',
label: 'mine',
value: EVENT_TYPES.UNASSIGNED,
label: 'unassigned',
},
{
value: 'all',
label: 'all',
value: EVENT_TYPES.NOTME,
label: 'notme',
},
];
+4 -4
View File
@@ -1,15 +1,15 @@
import fromUnixTime from 'date-fns/fromUnixTime';
import differenceInDays from 'date-fns/differenceInDays';
import Cookies from 'js-cookie';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
import {
ANALYTICS_IDENTITY,
ANALYTICS_RESET,
CHATWOOT_RESET,
CHATWOOT_SET_USER,
} from '../../helper/scriptHelpers';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
} from '../../constants/appEvents';
Cookies.defaults = { sameSite: 'Lax' };
-7
View File
@@ -6,7 +6,6 @@ import axios from 'axios';
import hljsVuePlugin from '@highlightjs/vue-plugin';
import Multiselect from 'vue-multiselect';
// import VueFormulate from '@braid/vue-formulate';
import { plugin, defaultConfig } from '@formkit/vue';
import WootSwitch from 'components/ui/Switch.vue';
import WootWizard from 'components/ui/Wizard.vue';
@@ -22,7 +21,6 @@ import router, { initalizeRouter } from 'dashboard/routes';
import store from 'dashboard/store';
import constants from 'dashboard/constants/globals';
import * as Sentry from '@sentry/vue';
// import { Integrations } from '@sentry/tracing';
import {
initializeAnalyticsEvents,
initializeChatwootEvents,
@@ -101,7 +99,6 @@ app.directive('on-clickaway', onClickaway);
// load common helpers into js
commonHelpers();
window.WOOT_STORE = store;
window.WootConstants = constants;
window.axios = createAxios(axios);
// [VITE] Disabled this we don't need it, we can use `useEmitter` directly
@@ -114,7 +111,3 @@ initalizeRouter();
window.onload = () => {
app.mount('#app');
};
window.addEventListener('load', () => {
window.playAudioAlert = () => {};
});
+7
View File
@@ -4,6 +4,7 @@ import i18nMessages from 'dashboard/i18n';
import { createI18n } from 'vue-i18n';
import { vResizeObserver } from '@vueuse/components';
import store from 'dashboard/store';
import FloatingVue from 'floating-vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer.js';
import { directive as onClickaway } from 'vue3-click-away';
@@ -17,6 +18,12 @@ const i18n = createI18n({
export const setupVue3 = defineSetupVue3(({ app }) => {
app.use(store);
app.use(i18n);
app.use(FloatingVue, {
instantMove: true,
arrowOverflow: false,
disposeTimeout: 5000000,
});
app.directive('resize', vResizeObserver);
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);