Merge branch 'develop' into feat/v5-ui-redesign
This commit is contained in:
@@ -61,6 +61,12 @@ class ReportsAPI extends ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
getConversationsSummaryReports({ from: since, to: until, businessHours }) {
|
||||
return axios.get(`${this.url}/conversations_summary`, {
|
||||
params: { since, until, business_hours: businessHours },
|
||||
});
|
||||
}
|
||||
|
||||
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||
return axios.get(`${this.url}/conversation_traffic`, {
|
||||
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||
|
||||
@@ -5,6 +5,7 @@ import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
editorKey: { type: String, default: '' },
|
||||
label: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
focusOnMount: { type: Boolean, default: false },
|
||||
@@ -96,6 +97,7 @@ watch(
|
||||
]"
|
||||
>
|
||||
<WootEditor
|
||||
:editor-id="editorKey"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:focus-on-mount="focusOnMount"
|
||||
@@ -152,6 +154,13 @@ watch(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-menubar {
|
||||
width: fit-content !important;
|
||||
position: relative !important;
|
||||
top: unset !important;
|
||||
@apply ltr:left-[-0.188rem] rtl:right-[-0.188rem] !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -44,6 +44,12 @@ const emit = defineEmits([
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const attachmentId = ref(0);
|
||||
const generateUid = () => {
|
||||
attachmentId.value += 1;
|
||||
return `attachment-${attachmentId.value}`;
|
||||
};
|
||||
|
||||
const uploadAttachment = ref(null);
|
||||
const isEmojiPickerOpen = ref(false);
|
||||
|
||||
@@ -176,7 +182,8 @@ const onPaste = e => {
|
||||
.filter(file => file.size > 0)
|
||||
.forEach(file => {
|
||||
const { name, type, size } = file;
|
||||
onFileUpload({ file, name, type, size });
|
||||
// Add unique ID for clipboard-pasted files
|
||||
onFileUpload({ file, name, type, size, id: generateUid() });
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+79
-61
@@ -7,6 +7,7 @@ import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
getEffectiveChannelType,
|
||||
stripUnsupportedMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
import {
|
||||
buildContactableInboxesList,
|
||||
@@ -47,6 +48,8 @@ const emit = defineEmits([
|
||||
'createConversation',
|
||||
]);
|
||||
|
||||
const DEFAULT_FORMATTING = 'Context::Default';
|
||||
|
||||
const showContactsDropdown = ref(false);
|
||||
const showInboxesDropdown = ref(false);
|
||||
const showCcEmailsDropdown = ref(false);
|
||||
@@ -198,10 +201,22 @@ const setSelectedContact = async ({ value, action, ...rest }) => {
|
||||
showInboxesDropdown.value = true;
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value, action, ...rest }) => {
|
||||
const stripMessageFormatting = channelType => {
|
||||
if (!state.message || !channelType) return;
|
||||
|
||||
state.message = stripUnsupportedMarkdown(state.message, channelType, false);
|
||||
};
|
||||
|
||||
const handleInboxAction = ({ value, action, channelType, medium, ...rest }) => {
|
||||
v$.value.$reset();
|
||||
state.message = '';
|
||||
emit('updateTargetInbox', { ...rest });
|
||||
|
||||
// Strip unsupported formatting when changing the target inbox
|
||||
if (channelType) {
|
||||
const newChannelType = getEffectiveChannelType(channelType, medium);
|
||||
stripMessageFormatting(newChannelType);
|
||||
}
|
||||
|
||||
emit('updateTargetInbox', { ...rest, channelType, medium });
|
||||
showInboxesDropdown.value = false;
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@@ -221,7 +236,9 @@ const removeSignatureFromMessage = () => {
|
||||
const removeTargetInbox = value => {
|
||||
v$.value.$reset();
|
||||
removeSignatureFromMessage();
|
||||
state.message = '';
|
||||
|
||||
stripMessageFormatting(DEFAULT_FORMATTING);
|
||||
|
||||
emit('updateTargetInbox', value);
|
||||
state.attachedFiles = [];
|
||||
};
|
||||
@@ -324,67 +341,68 @@ const shouldShowMessageEditor = computed(() => {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[42rem] 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 min-w-0"
|
||||
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0 max-h-[calc(100vh-8rem)]"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-n-strong">
|
||||
<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"
|
||||
/>
|
||||
<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="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:has-attachments="state.attachedFiles.length > 0"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
<MessageEditor
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
:has-errors="validationStates.isMessageInvalid"
|
||||
:channel-type="inboxChannelType"
|
||||
:medium="targetInbox?.medium || ''"
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ const targetInboxLabel = computed(() => {
|
||||
<DropdownMenu
|
||||
v-if="contactableInboxesList?.length > 0 && showInboxesDropdown"
|
||||
:menu-items="contactableInboxesList"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-fit max-w-sm dark:!outline-n-slate-5"
|
||||
@action="emit('handleInboxAction', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+3
-4
@@ -6,7 +6,6 @@ import Editor from 'dashboard/components-next/Editor/Editor.vue';
|
||||
|
||||
const props = defineProps({
|
||||
hasErrors: { type: Boolean, default: false },
|
||||
hasAttachments: { type: Boolean, default: false },
|
||||
sendWithSignature: { type: Boolean, default: false },
|
||||
messageSignature: { type: String, default: '' },
|
||||
channelType: { type: String, default: '' },
|
||||
@@ -24,14 +23,14 @@ const modelValue = defineModel({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 h-full" :class="[!hasAttachments && 'min-h-[200px]']">
|
||||
<div class="flex-1 h-full">
|
||||
<Editor
|
||||
:key="editorKey"
|
||||
v-model="modelValue"
|
||||
:editor-key="editorKey"
|
||||
: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="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 [&>div]:!bg-transparent h-full [&_.ProseMirror-woot-style]:!max-h-[12.5rem] [&_.ProseMirror-woot-style]:!min-h-[10rem] [&_.ProseMirror-menubar]:!pt-0 [&_.mention--box]:-top-[7.5rem] [&_.mention--box]:bottom-[unset]"
|
||||
:class="
|
||||
hasErrors
|
||||
? '[&_.empty-node]:before:!text-n-ruby-9 [&_.empty-node]:dark:before:!text-n-ruby-9'
|
||||
|
||||
@@ -11,7 +11,6 @@ export const CONVERSATION_ATTRIBUTES = {
|
||||
CAMPAIGN_ID: 'campaign_id',
|
||||
LABELS: 'labels',
|
||||
BROWSER_LANGUAGE: 'browser_language',
|
||||
COUNTRY_CODE: 'country_code',
|
||||
REFERER: 'referer',
|
||||
CREATED_AT: 'created_at',
|
||||
LAST_ACTIVITY_AT: 'last_activity_at',
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
buildAttributesFilterTypes,
|
||||
CONVERSATION_ATTRIBUTES,
|
||||
} from './helper/filterHelper';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
||||
|
||||
/**
|
||||
@@ -218,17 +217,6 @@ export function useConversationFilterContext() {
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
value: CONVERSATION_ATTRIBUTES.COUNTRY_CODE,
|
||||
attributeName: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
label: t('FILTER.ATTRIBUTES.COUNTRY_NAME'),
|
||||
inputType: 'searchSelect',
|
||||
options: countries,
|
||||
dataType: 'text',
|
||||
filterOperators: equalityOperators.value,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
value: CONVERSATION_ATTRIBUTES.REFERER,
|
||||
|
||||
@@ -230,7 +230,7 @@ const handleBlur = e => emit('blur', e);
|
||||
v-if="showDropdownMenu"
|
||||
:menu-items="filteredMenuItems"
|
||||
:is-searching="isLoading"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-60 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
class="ltr:left-0 rtl:right-0 z-[100] top-8 overflow-y-auto max-h-56 w-[inherit] max-w-md dark:!outline-n-slate-5"
|
||||
@action="handleDropdownAction"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -78,14 +78,6 @@ const filterTypes = [
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'country_code',
|
||||
attributeI18nKey: 'COUNTRY_NAME',
|
||||
inputType: 'search_select',
|
||||
dataType: 'text',
|
||||
filterOperators: OPERATOR_TYPES_1,
|
||||
attributeModel: 'additional',
|
||||
},
|
||||
{
|
||||
attributeKey: 'referer',
|
||||
attributeI18nKey: 'REFERER_LINK',
|
||||
@@ -171,10 +163,6 @@ export const filterAttributeGroups = [
|
||||
key: 'browser_language',
|
||||
i18nKey: 'BROWSER_LANGUAGE',
|
||||
},
|
||||
{
|
||||
key: 'country_code',
|
||||
i18nKey: 'COUNTRY_NAME',
|
||||
},
|
||||
{
|
||||
key: 'referer',
|
||||
i18nKey: 'REFERER_LINK',
|
||||
|
||||
@@ -38,9 +38,14 @@ export function extractTextFromMarkdown(markdown) {
|
||||
*
|
||||
* @param {string} markdown - markdown text to process
|
||||
* @param {string} channelType - The channel type to check supported formatting
|
||||
* @param {boolean} cleanWhitespace - Whether to clean up extra whitespace and blank lines (default: true for signatures)
|
||||
* @returns {string} - The markdown with unsupported formatting removed
|
||||
*/
|
||||
export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
export function stripUnsupportedMarkdown(
|
||||
markdown,
|
||||
channelType,
|
||||
cleanWhitespace = true
|
||||
) {
|
||||
if (!markdown) return '';
|
||||
|
||||
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
|
||||
@@ -55,6 +60,9 @@ export function stripUnsupportedSignatureMarkdown(markdown, channelType) {
|
||||
);
|
||||
}, markdown);
|
||||
|
||||
if (!cleanWhitespace) return result;
|
||||
|
||||
// Clean whitespace for signatures
|
||||
return result
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
@@ -155,7 +163,7 @@ export function getEffectiveChannelType(channelType, medium) {
|
||||
export function appendSignature(body, signature, channelType) {
|
||||
// Strip only unsupported formatting based on channel capabilities
|
||||
const preparedSignature = channelType
|
||||
? stripUnsupportedSignatureMarkdown(signature, channelType)
|
||||
? stripUnsupportedMarkdown(signature, channelType)
|
||||
: signature;
|
||||
const cleanedSignature = cleanSignature(preparedSignature);
|
||||
// if signature is already present, return body
|
||||
@@ -178,7 +186,7 @@ export function appendSignature(body, signature, channelType) {
|
||||
export function removeSignature(body, signature, channelType) {
|
||||
// Build unique list of signature variants to try
|
||||
const channelStripped = channelType
|
||||
? cleanSignature(stripUnsupportedSignatureMarkdown(signature, channelType))
|
||||
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
|
||||
: null;
|
||||
const signaturesToTry = [
|
||||
cleanSignature(signature),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
// Quote detection strategies
|
||||
const QUOTE_INDICATORS = [
|
||||
'.gmail_quote_container',
|
||||
@@ -29,7 +31,7 @@ export class EmailQuoteExtractor {
|
||||
static extractQuotes(htmlContent) {
|
||||
// Create a temporary DOM element to parse HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Remove elements matching class selectors
|
||||
QUOTE_INDICATORS.forEach(selector => {
|
||||
@@ -56,7 +58,7 @@ export class EmailQuoteExtractor {
|
||||
*/
|
||||
static hasQuotes(htmlContent) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = htmlContent;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
|
||||
|
||||
// Check for class-based quotes
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { format, parseISO, isValid as isValidDate } from 'date-fns';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
/**
|
||||
* Extracts plain text from HTML content
|
||||
@@ -13,7 +14,7 @@ export const extractPlainTextFromHtml = html => {
|
||||
return html.replace(/<[^>]*>/g, ' ');
|
||||
}
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
tempDiv.innerHTML = DOMPurify.sanitize(html);
|
||||
return tempDiv.textContent || tempDiv.innerText || '';
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
stripUnsupportedSignatureMarkdown,
|
||||
stripUnsupportedMarkdown,
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
@@ -145,25 +145,19 @@ describe('appendSignature', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
describe('stripUnsupportedMarkdown', () => {
|
||||
const richSignature =
|
||||
'**Bold** _italic_ [link](http://example.com) ';
|
||||
|
||||
it('keeps all formatting for Email channel (supports image, link, strong, em)', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Email'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Email');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).toContain('');
|
||||
});
|
||||
it('strips images but keeps bold/italic for Api channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Api'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Api');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('link'); // link text kept
|
||||
@@ -171,20 +165,14 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
expect(result).not.toContain('; // image removed
|
||||
});
|
||||
it('strips images but keeps bold/italic/link for Telegram channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Telegram'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Telegram');
|
||||
expect(result).toContain('**Bold**');
|
||||
expect(result).toContain('_italic_');
|
||||
expect(result).toContain('[link](http://example.com)');
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('strips all formatting for SMS channel', () => {
|
||||
const result = stripUnsupportedSignatureMarkdown(
|
||||
richSignature,
|
||||
'Channel::Sms'
|
||||
);
|
||||
const result = stripUnsupportedMarkdown(richSignature, 'Channel::Sms');
|
||||
expect(result).toContain('Bold');
|
||||
expect(result).toContain('italic');
|
||||
expect(result).toContain('link');
|
||||
@@ -194,8 +182,52 @@ describe('stripUnsupportedSignatureMarkdown', () => {
|
||||
expect(result).not.toContain(';
|
||||
});
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(stripUnsupportedSignatureMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedSignatureMarkdown(null, 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedMarkdown('', 'Channel::Api')).toBe('');
|
||||
expect(stripUnsupportedMarkdown(null, 'Channel::Api')).toBe('');
|
||||
});
|
||||
|
||||
describe('with cleanWhitespace parameter', () => {
|
||||
const textWithWhitespace =
|
||||
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces ';
|
||||
|
||||
it('cleans whitespace when cleanWhitespace=true (default)', () => {
|
||||
const result = stripUnsupportedMarkdown(
|
||||
textWithWhitespace,
|
||||
'Channel::Api',
|
||||
true
|
||||
);
|
||||
expect(result).toBe(
|
||||
'**Bold** text\nWith multiple\nLine breaks\nAnd spaces'
|
||||
);
|
||||
expect(result).not.toContain('\n\n');
|
||||
expect(result).not.toContain(' ');
|
||||
});
|
||||
|
||||
it('preserves whitespace when cleanWhitespace=false', () => {
|
||||
const result = stripUnsupportedMarkdown(
|
||||
textWithWhitespace,
|
||||
'Channel::Api',
|
||||
false
|
||||
);
|
||||
expect(result).toContain('\n\n');
|
||||
expect(result).toContain(' And spaces ');
|
||||
expect(result).toBe(
|
||||
'**Bold** text\n\nWith multiple\n\nLine breaks\n\n And spaces '
|
||||
);
|
||||
});
|
||||
|
||||
it('strips formatting but preserves whitespace for messages', () => {
|
||||
const messageWithFormatting = '**Bold**\n\n`code`\n\nNormal text';
|
||||
const result = stripUnsupportedMarkdown(
|
||||
messageWithFormatting,
|
||||
'Channel::Sms',
|
||||
false
|
||||
);
|
||||
expect(result).toBe('Bold\n\ncode\n\nNormal text');
|
||||
expect(result).toContain('\n\n');
|
||||
expect(result).not.toContain('**');
|
||||
expect(result).not.toContain('`');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -96,4 +96,58 @@ describe('EmailQuoteExtractor', () => {
|
||||
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
|
||||
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
|
||||
});
|
||||
|
||||
describe('HTML sanitization', () => {
|
||||
it('removes onerror handlers from img tags in extractQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).toContain('<p>Hello</p>');
|
||||
});
|
||||
|
||||
it('removes onerror handlers from img tags in hasQuotes', () => {
|
||||
const maliciousHtml = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
// Should not throw and should safely check for quotes
|
||||
const result = EmailQuoteExtractor.hasQuotes(maliciousHtml);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('removes script tags in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<p>Content</p><script>alert("xss")</script><p>More</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('<script');
|
||||
expect(cleanedHtml).not.toContain('alert');
|
||||
expect(cleanedHtml).toContain('<p>Content</p>');
|
||||
expect(cleanedHtml).toContain('<p>More</p>');
|
||||
});
|
||||
|
||||
it('removes onclick handlers in extractQuotes', () => {
|
||||
const maliciousHtml = '<p onclick="alert(1)">Click me</p>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onclick');
|
||||
expect(cleanedHtml).toContain('Click me');
|
||||
});
|
||||
|
||||
it('removes javascript: URLs in extractQuotes', () => {
|
||||
const maliciousHtml = '<a href="javascript:alert(1)">Link</a>';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
// eslint-disable-next-line no-script-url
|
||||
expect(cleanedHtml).not.toContain('javascript:');
|
||||
expect(cleanedHtml).toContain('Link');
|
||||
});
|
||||
|
||||
it('removes encoded payloads with event handlers in extractQuotes', () => {
|
||||
const maliciousHtml =
|
||||
'<img src="x" id="PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" onerror="eval(atob(this.id))">';
|
||||
const cleanedHtml = EmailQuoteExtractor.extractQuotes(maliciousHtml);
|
||||
|
||||
expect(cleanedHtml).not.toContain('onerror');
|
||||
expect(cleanedHtml).not.toContain('eval');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,26 @@ describe('quotedEmailHelper', () => {
|
||||
expect(result).toContain('Line 1');
|
||||
expect(result).toContain('Line 2');
|
||||
});
|
||||
|
||||
it('sanitizes onerror handlers from img tags', () => {
|
||||
const html = '<p>Hello</p><img src="x" onerror="alert(1)">';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Hello');
|
||||
});
|
||||
|
||||
it('sanitizes script tags', () => {
|
||||
const html = '<p>Safe</p><script>alert(1)</script><p>Content</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toContain('Safe');
|
||||
expect(result).toContain('Content');
|
||||
expect(result).not.toContain('alert');
|
||||
});
|
||||
|
||||
it('sanitizes onclick handlers', () => {
|
||||
const html = '<p onclick="alert(1)">Click me</p>';
|
||||
const result = extractPlainTextFromHtml(html);
|
||||
expect(result).toBe('Click me');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmailSenderName', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"HEADER": "Conversations",
|
||||
"LOADING_CHART": "Loading chart data...",
|
||||
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
|
||||
"DOWNLOAD_AGENT_REPORTS": "Download agent reports",
|
||||
"DOWNLOAD_CONVERSATION_REPORTS": "Download conversation reports",
|
||||
"DATA_FETCHING_FAILED": "Failed to fetch data, please try again later.",
|
||||
"SUMMARY_FETCHING_FAILED": "Failed to fetch summary, please try again later.",
|
||||
"METRICS": {
|
||||
|
||||
@@ -370,7 +370,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<section class="flex flex-col flex-grow w-full h-full overflow-hidden">
|
||||
<div class="w-full max-w-5xl mx-auto z-[60]">
|
||||
<div class="w-full max-w-5xl mx-auto z-30">
|
||||
<div class="flex flex-col w-full px-4">
|
||||
<SearchHeader
|
||||
v-model:filters="filters"
|
||||
|
||||
+59
-21
@@ -28,10 +28,15 @@ const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const { isAWhatsAppCloudChannel: isWhatsAppChannel } = useInbox(
|
||||
const { isAWhatsAppChannel, isATwilioWhatsAppChannel } = useInbox(
|
||||
props.inbox?.id
|
||||
);
|
||||
|
||||
// Computed to check if it's any type of WhatsApp channel (Cloud or Twilio)
|
||||
const isAnyWhatsAppChannel = computed(
|
||||
() => isAWhatsAppChannel.value || isATwilioWhatsAppChannel.value
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
const selectedLabelValues = ref([]);
|
||||
const currentLabel = ref('');
|
||||
@@ -116,7 +121,9 @@ const templateApprovalStatus = computed(() => {
|
||||
|
||||
// Handle existing template with status
|
||||
if (templateStatus.value?.template_exists && templateStatus.value.status) {
|
||||
return statusMap[templateStatus.value.status] || statusMap.PENDING;
|
||||
// Convert status to uppercase for consistency with statusMap keys
|
||||
const normalizedStatus = templateStatus.value.status.toUpperCase();
|
||||
return statusMap[normalizedStatus] || statusMap.PENDING;
|
||||
}
|
||||
|
||||
// Default case - no template exists
|
||||
@@ -155,7 +162,7 @@ const initializeState = () => {
|
||||
: [];
|
||||
|
||||
// Store original template values for change detection
|
||||
if (isWhatsAppChannel.value) {
|
||||
if (isAnyWhatsAppChannel.value) {
|
||||
originalTemplateValues.value = {
|
||||
message: state.message,
|
||||
templateButtonText: state.templateButtonText,
|
||||
@@ -165,7 +172,7 @@ const initializeState = () => {
|
||||
};
|
||||
|
||||
const checkTemplateStatus = async () => {
|
||||
if (!isWhatsAppChannel.value) return;
|
||||
if (!isAnyWhatsAppChannel.value) return;
|
||||
|
||||
try {
|
||||
templateLoading.value = true;
|
||||
@@ -195,7 +202,7 @@ const checkTemplateStatus = async () => {
|
||||
onMounted(() => {
|
||||
initializeState();
|
||||
if (!labels.value?.length) store.dispatch('labels/get');
|
||||
if (isWhatsAppChannel.value) checkTemplateStatus();
|
||||
if (isAnyWhatsAppChannel.value) checkTemplateStatus();
|
||||
});
|
||||
|
||||
watch(() => props.inbox, initializeState, { immediate: true });
|
||||
@@ -225,7 +232,7 @@ const removeLabel = label => {
|
||||
|
||||
// Check if template-related fields have changed
|
||||
const hasTemplateChanges = () => {
|
||||
if (!isWhatsAppChannel.value) return false;
|
||||
if (!isAnyWhatsAppChannel.value) return false;
|
||||
|
||||
const original = originalTemplateValues.value;
|
||||
return (
|
||||
@@ -254,10 +261,28 @@ const shouldCreateTemplate = () => {
|
||||
|
||||
// Build template config for saving
|
||||
const buildTemplateConfig = () => {
|
||||
if (!hasExistingTemplate()) return null;
|
||||
if (!hasExistingTemplate()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { template_name, template_id, template, status } =
|
||||
templateStatus.value || {};
|
||||
|
||||
if (isATwilioWhatsAppChannel.value) {
|
||||
// Twilio WhatsApp format - get from existing template config
|
||||
const existingTemplate = props.inbox?.csat_config?.template;
|
||||
|
||||
return existingTemplate
|
||||
? {
|
||||
friendly_name: existingTemplate.friendly_name,
|
||||
content_sid: existingTemplate.content_sid,
|
||||
language: existingTemplate.language || state.templateLanguage,
|
||||
status: existingTemplate.status || status,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
// WhatsApp Cloud format
|
||||
return {
|
||||
name: template_name,
|
||||
template_id,
|
||||
@@ -273,11 +298,11 @@ const updateInbox = async attributes => {
|
||||
...attributes,
|
||||
};
|
||||
|
||||
return store.dispatch('inboxes/updateInbox', payload);
|
||||
await store.dispatch('inboxes/updateInbox', payload);
|
||||
};
|
||||
|
||||
const createTemplate = async () => {
|
||||
if (!isWhatsAppChannel.value) return null;
|
||||
if (!isAnyWhatsAppChannel.value) return null;
|
||||
|
||||
const response = await store.dispatch('inboxes/createCSATTemplate', {
|
||||
inboxId: props.inbox.id,
|
||||
@@ -298,7 +323,7 @@ const performSave = async () => {
|
||||
|
||||
// For WhatsApp channels, create template first if needed
|
||||
if (
|
||||
isWhatsAppChannel.value &&
|
||||
isAnyWhatsAppChannel.value &&
|
||||
state.csatSurveyEnabled &&
|
||||
shouldCreateTemplate()
|
||||
) {
|
||||
@@ -326,13 +351,25 @@ const performSave = async () => {
|
||||
|
||||
// Use new template data if created, otherwise preserve existing template information
|
||||
if (newTemplateData) {
|
||||
csatConfig.template = {
|
||||
name: newTemplateData.name,
|
||||
template_id: newTemplateData.template_id,
|
||||
language: newTemplateData.language,
|
||||
status: newTemplateData.status,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
if (isATwilioWhatsAppChannel.value) {
|
||||
// Twilio WhatsApp template format
|
||||
csatConfig.template = {
|
||||
friendly_name: newTemplateData.friendly_name,
|
||||
content_sid: newTemplateData.content_sid,
|
||||
language: newTemplateData.language,
|
||||
status: newTemplateData.status,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
// WhatsApp Cloud template format
|
||||
csatConfig.template = {
|
||||
name: newTemplateData.name,
|
||||
template_id: newTemplateData.template_id,
|
||||
language: newTemplateData.language,
|
||||
status: newTemplateData.status,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const templateConfig = buildTemplateConfig();
|
||||
if (templateConfig) {
|
||||
@@ -356,8 +393,9 @@ const performSave = async () => {
|
||||
|
||||
const saveSettings = async () => {
|
||||
// Check if we need to show confirmation dialog for WhatsApp template changes
|
||||
// This applies to both WhatsApp Cloud and Twilio WhatsApp channels
|
||||
if (
|
||||
isWhatsAppChannel.value &&
|
||||
isAnyWhatsAppChannel.value &&
|
||||
state.csatSurveyEnabled &&
|
||||
hasExistingTemplate() &&
|
||||
hasTemplateChanges()
|
||||
@@ -390,7 +428,7 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
<div class="grid gap-5">
|
||||
<!-- Show display type only for non-WhatsApp channels -->
|
||||
<WithLabel
|
||||
v-if="!isWhatsAppChannel"
|
||||
v-if="!isAnyWhatsAppChannel"
|
||||
:label="$t('INBOX_MGMT.CSAT.DISPLAY_TYPE.LABEL')"
|
||||
name="display_type"
|
||||
>
|
||||
@@ -400,7 +438,7 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
/>
|
||||
</WithLabel>
|
||||
|
||||
<template v-if="isWhatsAppChannel">
|
||||
<template v-if="isAnyWhatsAppChannel">
|
||||
<div
|
||||
class="flex flex-col gap-4 justify-between w-full lg:flex-row lg:gap-6"
|
||||
>
|
||||
@@ -536,7 +574,7 @@ const handleConfirmTemplateUpdate = async () => {
|
||||
</WithLabel>
|
||||
<p class="text-sm italic text-n-slate-11">
|
||||
{{
|
||||
isWhatsAppChannel
|
||||
isAnyWhatsAppChannel
|
||||
? $t('INBOX_MGMT.CSAT.WHATSAPP_NOTE')
|
||||
: $t('INBOX_MGMT.CSAT.NOTE')
|
||||
}}
|
||||
|
||||
@@ -76,14 +76,14 @@ export default {
|
||||
businessHours,
|
||||
};
|
||||
},
|
||||
downloadAgentReports() {
|
||||
downloadConversationReports() {
|
||||
const { from, to } = this;
|
||||
const fileName = generateFileName({
|
||||
type: 'agent',
|
||||
type: 'conversation',
|
||||
to,
|
||||
businessHours: this.businessHours,
|
||||
});
|
||||
this.$store.dispatch('downloadAgentReports', {
|
||||
this.$store.dispatch('downloadConversationsSummaryReports', {
|
||||
from,
|
||||
to,
|
||||
fileName,
|
||||
@@ -109,10 +109,10 @@ export default {
|
||||
<template>
|
||||
<ReportHeader :header-title="$t('REPORT.HEADER')">
|
||||
<V4Button
|
||||
:label="$t('REPORT.DOWNLOAD_AGENT_REPORTS')"
|
||||
:label="$t('REPORT.DOWNLOAD_CONVERSATION_REPORTS')"
|
||||
icon="i-ph-download-simple"
|
||||
size="sm"
|
||||
@click="downloadAgentReports"
|
||||
@click="downloadConversationReports"
|
||||
/>
|
||||
</ReportHeader>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -78,7 +78,6 @@ const getValueFromConversation = (conversation, attributeKey) => {
|
||||
case 'team_id':
|
||||
return conversation.meta?.team?.id;
|
||||
case 'browser_language':
|
||||
case 'country_code':
|
||||
case 'referer':
|
||||
return conversation.additional_attributes?.[attributeKey];
|
||||
default:
|
||||
|
||||
@@ -234,6 +234,19 @@ export const actions = {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
downloadConversationsSummaryReports(_, reportObj) {
|
||||
return Report.getConversationsSummaryReports(reportObj)
|
||||
.then(response => {
|
||||
downloadCsvFile(reportObj.fileName, response.data);
|
||||
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
|
||||
reportType: 'conversations_summary',
|
||||
businessHours: reportObj?.businessHours,
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
downloadLabelReports(_, reportObj) {
|
||||
return Report.getLabelReports(reportObj)
|
||||
.then(response => {
|
||||
|
||||
@@ -201,4 +201,24 @@ describe('#actions', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#downloadConversationsSummaryReports', () => {
|
||||
it('open CSV download prompt if API is success', async () => {
|
||||
const data = `Conversations,Messages received,Messages sent,Avg first response time,Avg resolution time,Resolution count,Avg customer waiting time
|
||||
217,323,623,23 hours 22 minutes,179 days 18 hours,30,48 days 4 hours`;
|
||||
axios.get.mockResolvedValue({ data });
|
||||
const param = {
|
||||
from: 1631039400,
|
||||
to: 1635013800,
|
||||
fileName: 'conversations-summary-report-24-10-2021.csv',
|
||||
};
|
||||
actions.downloadConversationsSummaryReports(1, param);
|
||||
await flushPromises();
|
||||
|
||||
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
|
||||
param.fileName,
|
||||
data
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export default {
|
||||
const { websiteToken, locale, widgetColor } = window.chatwootWebChannel;
|
||||
this.setLocale(locale);
|
||||
this.setWidgetColor(widgetColor);
|
||||
this.setWidgetColorVariable(widgetColor);
|
||||
setHeader(window.authToken);
|
||||
if (this.isIFrame) {
|
||||
this.registerListeners();
|
||||
@@ -114,6 +115,14 @@ export default {
|
||||
'resetCampaign',
|
||||
]),
|
||||
...mapActions('agent', ['fetchAvailableAgents']),
|
||||
setWidgetColorVariable(widgetColor) {
|
||||
if (widgetColor) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--widget-color',
|
||||
widgetColor
|
||||
);
|
||||
}
|
||||
},
|
||||
scrollConversationToBottom() {
|
||||
const container = this.$el.querySelector('.conversation-wrap');
|
||||
container.scrollTop = container.scrollHeight;
|
||||
|
||||
@@ -130,7 +130,7 @@ export default {
|
||||
<div
|
||||
class="items-center flex ltr:pl-3 rtl:pr-3 ltr:pr-2 rtl:pl-2 rounded-[7px] transition-all duration-200 bg-n-background !shadow-[0_0_0_1px,0_0_2px_3px]"
|
||||
:class="{
|
||||
'!shadow-n-brand dark:!shadow-n-brand': isFocused,
|
||||
'!shadow-[var(--widget-color,#2781f6)]': isFocused,
|
||||
'!shadow-n-strong dark:!shadow-n-strong': !isFocused,
|
||||
}"
|
||||
@keydown.esc="hideEmojiPicker"
|
||||
|
||||
Reference in New Issue
Block a user