Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,31 @@
import Auth from '../api/auth';
const parseErrorCode = error => Promise.reject(error);
export default axios => {
const { apiHost = '' } = window.chatwootConfig || {};
const wootApi = axios.create({ baseURL: `${apiHost}/` });
// Add Auth Headers to requests if logged in
if (Auth.hasAuthCookie()) {
const {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
} = Auth.getAuthData();
Object.assign(wootApi.defaults.headers.common, {
'access-token': accessToken,
'token-type': tokenType,
client,
expiry,
uid,
});
}
// Response parsing interceptor
wootApi.interceptors.response.use(
response => response,
error => parseErrorCode(error)
);
return wootApi;
};

View File

@@ -0,0 +1,151 @@
export const CONVERSATION_EVENTS = Object.freeze({
EXECUTED_A_MACRO: 'Executed a macro',
SENT_MESSAGE: 'Sent a message',
SENT_PRIVATE_NOTE: 'Sent a private note',
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
INSERTED_A_TOOL: 'Inserted a tool',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
});
export const ACCOUNT_EVENTS = Object.freeze({
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox',
OPEN_MESSAGE_CONTEXT_MENU: 'Opened message context menu',
OPENED_NOTIFICATIONS: 'Opened notifications',
MARK_AS_READ_NOTIFICATIONS: 'Marked notifications as read',
OPEN_CONVERSATION_VIA_NOTIFICATION: 'Opened conversation via notification',
});
export const LABEL_EVENTS = Object.freeze({
CREATE: 'Created a label',
UPDATE: 'Updated a label',
DELETED: 'Deleted a label',
APPLY_LABEL: 'Applied a label',
});
// REPORTS EVENTS
export const REPORTS_EVENTS = Object.freeze({
DOWNLOAD_REPORT: 'Downloaded a report',
FILTER_REPORT: 'Used filters in the reports',
});
// CONTACTS PAGE EVENTS
export const CONTACTS_EVENTS = Object.freeze({
APPLY_FILTER: 'Applied filters in the contacts list',
SAVE_FILTER: 'Saved a filter in the contacts list',
DELETE_FILTER: 'Deleted a filter in the contacts list',
APPLY_SORT: 'Sorted contacts list',
SEARCH: 'Searched contacts list',
CREATE_CONTACT: 'Created a contact',
MERGED_CONTACTS: 'Used merge contact option',
IMPORT_MODAL_OPEN: 'Opened import contacts modal',
IMPORT_FAILURE: 'Import contacts failed',
IMPORT_SUCCESS: 'Imported contacts successfully',
});
// CAMPAIGN EVENTS
export const CAMPAIGNS_EVENTS = Object.freeze({
OPEN_NEW_CAMPAIGN_MODAL: 'Opened new campaign modal',
CREATE_CAMPAIGN: 'Created a new campaign',
UPDATE_CAMPAIGN: 'Updated a campaign',
DELETE_CAMPAIGN: 'Deleted a campaign',
});
// PORTAL EVENTS
export const PORTALS_EVENTS = Object.freeze({
ONBOARD_BASIC_INFORMATION: 'New Portal: Completed basic information',
ONBOARD_CUSTOMIZATION: 'New portal: Completed customization',
CREATE_PORTAL: 'Created a portal',
DELETE_PORTAL: 'Deleted a portal',
UPDATE_PORTAL: 'Updated a portal',
CREATE_LOCALE: 'Created a portal locale',
SET_DEFAULT_LOCALE: 'Set default portal locale',
DELETE_LOCALE: 'Deleted a portal locale',
SWITCH_LOCALE: 'Switched portal locale',
CREATE_CATEGORY: 'Created a portal category',
DELETE_CATEGORY: 'Deleted a portal category',
EDIT_CATEGORY: 'Edited a portal category',
CREATE_ARTICLE: 'Created an article',
PUBLISH_ARTICLE: 'Published an article',
ARCHIVE_ARTICLE: 'Archived an article',
DELETE_ARTICLE: 'Deleted an article',
PREVIEW_ARTICLE: 'Previewed article',
});
export const CAPTAIN_EVENTS = Object.freeze({
// Editor funnel events
EDITOR_AI_MENU_OPENED: 'Captain: Editor AI menu opened',
GENERATION_FAILED: 'Captain: Generation failed',
AI_ASSISTED_MESSAGE_SENT: 'Captain: AI-assisted message sent',
// Rewrite events (with operation attribute in payload)
REWRITE_USED: 'Captain: Rewrite used',
REWRITE_APPLIED: 'Captain: Rewrite applied',
REWRITE_DISMISSED: 'Captain: Rewrite dismissed',
// Summarize events
SUMMARIZE_USED: 'Captain: Summarize used',
SUMMARIZE_APPLIED: 'Captain: Summarize applied',
SUMMARIZE_DISMISSED: 'Captain: Summarize dismissed',
// Reply suggestion events
REPLY_SUGGESTION_USED: 'Captain: Reply suggestion used',
REPLY_SUGGESTION_APPLIED: 'Captain: Reply suggestion applied',
REPLY_SUGGESTION_DISMISSED: 'Captain: Reply suggestion dismissed',
// Follow-up events
FOLLOW_UP_SENT: 'Captain: Follow-up sent',
// Label suggestions
LABEL_SUGGESTION_APPLIED: 'Captain: Label suggestion applied',
LABEL_SUGGESTION_DISMISSED: 'Captain: Label suggestion dismissed',
});
export const COPILOT_EVENTS = Object.freeze({
SEND_SUGGESTED: 'Copilot: Send suggested message',
SEND_MESSAGE: 'Copilot: Sent a message',
USE_CAPTAIN_RESPONSE: 'Copilot: Used captain response',
});
export const GENERAL_EVENTS = Object.freeze({
COMMAND_BAR: 'Used commandbar',
});
export const INBOX_EVENTS = Object.freeze({
OPEN_CONVERSATION_VIA_INBOX: 'Opened conversation via inbox',
MARK_NOTIFICATION_AS_READ: 'Marked notification as read',
MARK_ALL_NOTIFICATIONS_AS_READ: 'Marked all notifications as read',
MARK_NOTIFICATION_AS_UNREAD: 'Marked notification as unread',
DELETE_NOTIFICATION: 'Deleted notification',
DELETE_ALL_NOTIFICATIONS: 'Deleted all notifications',
});
export const SLA_EVENTS = Object.freeze({
CREATE: 'Created an SLA',
UPDATE: 'Updated an SLA',
DELETED: 'Deleted an SLA',
});
export const LINEAR_EVENTS = Object.freeze({
CREATE_ISSUE: 'Created a linear issue',
LINK_ISSUE: 'Linked a linear issue',
UNLINK_ISSUE: 'Unlinked a linear issue',
});
export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
MODAL_OPENED: 'Year in Review: Modal opened',
NEXT_CLICKED: 'Year in Review: Next clicked',
SHARE_CLICKED: 'Year in Review: Share clicked',
});

View File

@@ -0,0 +1,98 @@
import * as amplitude from '@amplitude/analytics-browser';
/**
* AnalyticsHelper class to initialize and track user analytics
* @class AnalyticsHelper
*/
export class AnalyticsHelper {
/**
* @constructor
* @param {Object} [options={}] - options for analytics
* @param {string} [options.token] - analytics token
*/
constructor({ token: analyticsToken } = {}) {
this.analyticsToken = analyticsToken;
this.analytics = null;
this.user = {};
}
/**
* Initialize analytics
* @function
* @async
*/
async init() {
if (!this.analyticsToken) {
return;
}
amplitude.init(this.analyticsToken, {
defaultTracking: false,
});
this.analytics = amplitude;
}
/**
* Identify the user
* @function
* @param {Object} user - User object
*/
identify(user) {
if (!this.analytics || !user) {
return;
}
this.user = user;
this.analytics.setUserId(`user-${this.user.id.toString()}`);
const identifyEvent = new amplitude.Identify();
identifyEvent.set('email', this.user.email);
identifyEvent.set('name', this.user.name);
identifyEvent.set('avatar', this.user.avatar_url);
this.analytics.identify(identifyEvent);
const { accounts, account_id: accountId } = this.user;
const [currentAccount] = accounts.filter(
account => account.id === accountId
);
if (currentAccount) {
const groupId = `account-${currentAccount.id.toString()}`;
this.analytics.setGroup('company', groupId);
const groupIdentify = new amplitude.Identify();
groupIdentify.set('name', currentAccount.name);
this.analytics.groupIdentify('company', groupId, groupIdentify);
}
}
/**
* Track any event
* @function
* @param {string} eventName - event name
* @param {Object} [properties={}] - event properties
*/
track(eventName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track(eventName, properties);
}
/**
* Track the page views
* @function
* @param {string} pageName - Page name
* @param {Object} [properties={}] - Page view properties
*/
page(pageName, properties = {}) {
if (!this.analytics) {
return;
}
this.analytics.track('$pageview', { pageName, ...properties });
}
}
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
export default new AnalyticsHelper(window.analyticsConfig);

View File

@@ -0,0 +1,26 @@
import * as AnalyticsEvents from '../events';
describe('Analytics Events', () => {
it('should be frozen', () => {
Object.entries(AnalyticsEvents).forEach(([, value]) => {
expect(Object.isFrozen(value)).toBe(true);
});
});
it('event names should be unique across the board', () => {
const allValues = Object.values(AnalyticsEvents).reduce(
(acc, curr) => acc.concat(Object.values(curr)),
[]
);
const uniqueValues = new Set(allValues);
expect(allValues.length).toBe(uniqueValues.size);
});
it('should not allow properties to be modified', () => {
Object.values(AnalyticsEvents).forEach(eventsObject => {
expect(() => {
eventsObject.NEW_PROPERTY = 'new value';
}).toThrow();
});
});
});

View File

@@ -0,0 +1,144 @@
import helperObject, { AnalyticsHelper } from '../';
vi.mock('@amplitude/analytics-browser', () => ({
init: vi.fn(),
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
track: vi.fn(),
Identify: vi.fn(() => ({
set: vi.fn(),
})),
}));
describe('helperObject', () => {
it('should return an instance of AnalyticsHelper', () => {
expect(helperObject).toBeInstanceOf(AnalyticsHelper);
});
});
describe('AnalyticsHelper', () => {
let analyticsHelper;
beforeEach(() => {
analyticsHelper = new AnalyticsHelper({ token: 'test_token' });
});
describe('init', () => {
it('should initialize amplitude with the correct token', async () => {
await analyticsHelper.init();
expect(analyticsHelper.analytics).not.toBe(null);
});
it('should not initialize amplitude if token is not provided', async () => {
analyticsHelper = new AnalyticsHelper();
await analyticsHelper.init();
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('identify', () => {
beforeEach(() => {
analyticsHelper.analytics = {
setUserId: vi.fn(),
identify: vi.fn(),
setGroup: vi.fn(),
groupIdentify: vi.fn(),
};
});
it('should call setUserId and identify on amplitude with correct arguments', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 1,
});
expect(analyticsHelper.analytics.setUserId).toHaveBeenCalledWith(
'user-123'
);
expect(analyticsHelper.analytics.identify).toHaveBeenCalled();
expect(analyticsHelper.analytics.setGroup).toHaveBeenCalledWith(
'company',
'account-1'
);
expect(analyticsHelper.analytics.groupIdentify).toHaveBeenCalled();
});
it('should call identify on amplitude without group', () => {
analyticsHelper.identify({
id: 123,
email: 'test@example.com',
name: 'Test User',
avatar_url: 'avatar_url',
accounts: [{ id: 1, name: 'Account 1' }],
account_id: 5,
});
expect(analyticsHelper.analytics.setGroup).not.toHaveBeenCalled();
});
it('should not call analytics methods if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.identify({});
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('track', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
analyticsHelper.user = { id: 123 };
});
it('should call track on amplitude with correct arguments', () => {
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event',
{ prop1: 'value1', prop2: 'value2' }
);
});
it('should call track on amplitude with default properties', () => {
analyticsHelper.track('Test Event');
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'Test Event',
{}
);
});
it('should not call track on amplitude if analytics is not initialized', () => {
analyticsHelper.analytics = null;
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
expect(analyticsHelper.analytics).toBe(null);
});
});
describe('page', () => {
beforeEach(() => {
analyticsHelper.analytics = { track: vi.fn() };
});
it('should call the track method for pageview with the correct arguments', () => {
const pageName = 'home';
const properties = {
path: '/test',
name: 'home',
};
analyticsHelper.page(pageName, properties);
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith(
'$pageview',
{ pageName: 'home', path: '/test', name: 'home' }
);
});
it('should not call analytics.track if analytics is null', () => {
analyticsHelper.analytics = null;
analyticsHelper.page('home');
expect(analyticsHelper.analytics).toBe(null);
});
});
});

View File

@@ -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;

View File

@@ -0,0 +1,47 @@
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
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);
};
isMessageFromPendingConversation = (message = {}) => {
const { conversation_id: conversationId } = message || {};
if (!conversationId) return false;
const activeConversation =
this.store.getters.getConversationById(conversationId);
return activeConversation?.status === wootConstants.STATUS_TYPE.PENDING;
};
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;

View File

@@ -0,0 +1,217 @@
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 {
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'];
export class DashboardAudioNotificationHelper {
constructor(store) {
if (!store) {
throw new Error('store is required');
}
this.store = new AudioNotificationStore(store);
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;
}
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 = DEFAULT_ALERT_TYPE,
audioAlertTone = DEFAULT_TONE,
}) => {
this.notificationConfig = {
...this.notificationConfig,
audioAlertType: audioAlertType.split('+').filter(Boolean),
playAlertOnlyWhenHidden: !alwaysPlayAudioAlert,
alertIfUnreadConversationExist: alertIfUnreadConversationExist,
};
this.currentUser = currentUser;
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 (this.store.hasUnreadConversation() && this.shouldPlayAlert()) {
this.playAudioAlert();
showBadgeOnFavicon();
}
this.resetRecurringTimer();
};
clearRecurringTimer = () => {
if (this.recurringNotificationTimer) {
clearTimeout(this.recurringNotificationTimer);
}
};
resetRecurringTimer = () => {
this.clearRecurringTimer();
this.recurringNotificationTimer = setTimeout(
this.executeRecurringNotification,
NOTIFICATION_TIME
);
};
playAudioEvery30Seconds = () => {
const { audioAlertType, alertIfUnreadConversationExist } =
this.notificationConfig;
// Audio alert is disabled dismiss the timer
if (audioAlertType.includes('none')) return;
// If unread conversation flag is disabled, dismiss the timer
if (!alertIfUnreadConversationExist) return;
this.resetRecurringTimer();
};
shouldNotifyOnMessage = 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) ||
audioAlertType.includes('mine')
) {
shouldPlayAudio.push(assignedToMe);
}
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
// 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 conversation status is pending, then dismiss the alert
// This case is common for all audio event types
if (this.store.isMessageFromPendingConversation(message)) {
return;
}
// 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;
}
// If the message type is not incoming or private, then dismiss the alert
const { message_type: messageType, private: isPrivate } = message;
if (messageType !== MESSAGE_TYPE.INCOMING && !isPrivate) {
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;
}
}
this.playAudioAlert();
showBadgeOnFavicon();
this.playAudioEvery30Seconds();
};
}
export default new DashboardAudioNotificationHelper(GlobalStore);

View File

@@ -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();

View File

@@ -0,0 +1,21 @@
export const showBadgeOnFavicon = () => {
const favicons = document.querySelectorAll('.favicon');
favicons.forEach(favicon => {
const newFileName = `/favicon-badge-${favicon.sizes[[0]]}.png`;
favicon.href = newFileName;
});
};
export const initFaviconSwitcher = () => {
const favicons = document.querySelectorAll('.favicon');
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
favicons.forEach(favicon => {
const oldFileName = `/favicon-${favicon.sizes[[0]]}.png`;
favicon.href = oldFileName;
});
}
});
};

View File

@@ -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);
});
});

View File

@@ -0,0 +1,191 @@
import AudioNotificationStore from '../AudioNotificationStore';
import {
ROLES,
CONVERSATION_PERMISSIONS,
} from 'dashboard/constants/permissions';
import { getUserPermissions } from 'dashboard/helper/permissionsHelper';
import wootConstants from 'dashboard/constants/globals';
vi.mock('dashboard/helper/permissionsHelper', () => ({
getUserPermissions: vi.fn(),
}));
describe('AudioNotificationStore', () => {
let store;
let audioNotificationStore;
beforeEach(() => {
store = {
getters: {
getMineChats: vi.fn(),
getSelectedChat: null,
getCurrentAccountId: 1,
getConversationById: vi.fn(),
},
};
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('isMessageFromPendingConversation', () => {
it('should return true when conversation status is pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.PENDING,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(true);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation status is not pending', () => {
store.getters.getConversationById.mockReturnValue({
id: 123,
status: wootConstants.STATUS_TYPE.OPEN,
});
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when conversation is not found', () => {
store.getters.getConversationById.mockReturnValue(null);
const message = { conversation_id: 123 };
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).toHaveBeenCalledWith(123);
});
it('should return false when message has no conversation_id', () => {
const message = {};
expect(
audioNotificationStore.isMessageFromPendingConversation(message)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
it('should return false when message is null or undefined', () => {
expect(
audioNotificationStore.isMessageFromPendingConversation(null)
).toBe(false);
expect(
audioNotificationStore.isMessageFromPendingConversation(undefined)
).toBe(false);
expect(store.getters.getConversationById).not.toHaveBeenCalled();
});
});
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
);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,78 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
export class DataManager {
constructor(accountId) {
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
async initDb() {
if (this.db) return this.db;
const dbName = `cw-store-${this.accountId}`;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
// Store the database name in LocalStorage
const dbNames = JSON.parse(localStorage.getItem('cw-idb-names') || '[]');
if (!dbNames.includes(dbName)) {
dbNames.push(dbName);
localStorage.setItem('cw-idb-names', JSON.stringify(dbNames));
}
return this.db;
}
validateModel(name) {
if (!name) throw new Error('Model name is not defined');
if (!this.modelsToSync.includes(name)) {
throw new Error(`Model ${name} is not defined`);
}
return true;
}
async replace({ modelName, data }) {
this.validateModel(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
async push({ modelName, data }) {
this.validateModel(modelName);
if (Array.isArray(data)) {
const tx = this.db.transaction(modelName, 'readwrite');
data.forEach(item => {
tx.store.add(item);
});
await tx.done;
} else {
await this.db.add(modelName, data);
}
}
async get({ modelName }) {
this.validateModel(modelName);
return this.db.getAll(modelName);
}
async setCacheKeys(cacheKeys) {
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
this.validateModel(modelName);
return this.db.get('cache-keys', modelName);
}
}

View File

@@ -0,0 +1,3 @@
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';

View File

@@ -0,0 +1,124 @@
const SCRIPT_TYPE = 'text/javascript';
const DATA_LOADED_ATTR = 'data-loaded';
const SCRIPT_PROPERTIES = [
'defer',
'crossOrigin',
'noModule',
'referrerPolicy',
'id',
];
/**
* Custom error class for script loading failures.
* @extends Error
*/
class ScriptLoaderError extends Error {
/**
* Creates a new ScriptLoaderError.
* @param {string} src - The source URL of the script that failed to load.
* @param {string} message - The error message.
*/
constructor(src, message = 'Failed to load script') {
super(message);
this.name = 'ScriptLoaderError';
this.src = src;
}
/**
* Gets detailed error information.
* @returns {string} A string containing the error details.
*/
getErrorDetails() {
return `Failed to load script from source: ${this.src}`;
}
}
/**
* Creates a new script element with the specified attributes.
* @param {string} src - The source URL of the script.
* @param {Object} options - Options for configuring the script element.
* @param {string} [options.type='text/javascript'] - The type of the script.
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
* @param {string} [options.crossOrigin] - The CORS setting for the script.
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
* @param {string} [options.id] - The id attribute for the script element.
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
* @returns {HTMLScriptElement} The created script element.
*/
const createScriptElement = (src, options) => {
const el = document.createElement('script');
el.type = options.type || SCRIPT_TYPE;
el.async = options.async !== false;
el.src = src;
SCRIPT_PROPERTIES.forEach(property => {
if (property in options) {
el[property] = options[property];
}
});
Object.entries(options.attrs || {}).forEach(([name, value]) =>
el.setAttribute(name, value)
);
return el;
};
/**
* Finds an existing script element with the specified source URL.
* @param {string} src - The source URL to search for.
* @returns {HTMLScriptElement|null} The found script element, or null if not found.
*/
const findExistingScript = src => {
return document.querySelector(`script[src="${src}"]`);
};
/**
* Loads a script asynchronously and returns a promise.
* @param {string} src - The source URL of the script to load.
* @param {Object} options - Options for configuring the script element.
* @param {string} [options.type='text/javascript'] - The type of the script.
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
* @param {string} [options.crossOrigin] - The CORS setting for the script.
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
* @param {string} [options.id] - The id attribute for the script element.
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
* @returns {Promise<HTMLScriptElement|boolean>} A promise that resolves with the loaded script element,
* or false if the script couldn't be loaded.
* @throws {ScriptLoaderError} If the script fails to load.
*/
export async function loadScript(src, options) {
if (typeof window === 'undefined' || !window.document) {
return Promise.resolve(false);
}
return new Promise((resolve, reject) => {
if (typeof src !== 'string' || src.trim() === '') {
reject(new Error('Invalid source URL provided'));
return;
}
let el = findExistingScript(src);
if (!el) {
el = createScriptElement(src, options);
document.head.appendChild(el);
} else if (el.hasAttribute(DATA_LOADED_ATTR)) {
resolve(el);
return;
}
const handleError = () => reject(new ScriptLoaderError(src));
el.addEventListener('error', handleError);
el.addEventListener('abort', handleError);
el.addEventListener('load', () => {
el.setAttribute(DATA_LOADED_ATTR, 'true');
resolve(el);
});
});
}

View File

@@ -0,0 +1,148 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
const MAX_DISCONNECT_SECONDS = 10800;
// The disconnect delay threshold is added to account for delays in identifying
// disconnections (for example, the websocket disconnection takes up to 3 seconds)
// while fetching the latest updated conversations or messages.
const DISCONNECT_DELAY_THRESHOLD = 15;
class ReconnectService {
constructor(store, router) {
this.store = store;
this.router = router;
this.disconnectTime = null;
this.setupEventListeners();
}
disconnect = () => this.removeEventListeners();
setupEventListeners = () => {
window.addEventListener('online', this.handleOnlineEvent);
emitter.on(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
removeEventListeners = () => {
window.removeEventListener('online', this.handleOnlineEvent);
emitter.off(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.off(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
getSecondsSinceDisconnect = () =>
this.disconnectTime
? Math.max(differenceInSeconds(new Date(), this.disconnectTime), 0)
: 0;
// Force reload if the user is disconnected for more than 3 hours
handleOnlineEvent = () => {
if (this.getSecondsSinceDisconnect() >= MAX_DISCONNECT_SECONDS) {
window.location.reload();
}
};
fetchConversations = async () => {
await this.store.dispatch('updateChatListFilters', {
page: null,
updatedWithin:
this.getSecondsSinceDisconnect() + DISCONNECT_DELAY_THRESHOLD,
});
await this.store.dispatch('fetchAllConversations');
// Reset the updatedWithin in the store chat list filter after fetching conversations when the user is reconnected
await this.store.dispatch('updateChatListFilters', {
updatedWithin: null,
});
};
fetchFilteredOrSavedConversations = async queryData => {
await this.store.dispatch('fetchFilteredConversations', {
queryData,
page: 1,
});
};
fetchConversationsOnReconnect = async () => {
const {
getAppliedConversationFiltersQuery,
'customViews/getActiveConversationFolder': activeFolder,
} = this.store.getters;
const query = getAppliedConversationFiltersQuery?.payload?.length
? getAppliedConversationFiltersQuery
: activeFolder?.query;
if (query) {
await this.fetchFilteredOrSavedConversations(query);
} else {
await this.fetchConversations();
}
};
fetchConversationMessagesOnReconnect = async () => {
const { conversation_id: conversationId } =
this.router.currentRoute.value.params;
if (conversationId) {
await this.store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversationId),
});
}
};
fetchNotificationsOnReconnect = async filter => {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.value.name;
if (isAConversationRoute(currentRoute, true)) {
await this.fetchConversationsOnReconnect();
await this.fetchConversationMessagesOnReconnect();
} else if (isAInboxViewRoute(currentRoute, true)) {
await this.fetchNotificationsOnReconnect(
this.store.getters['notifications/getNotificationFilters']
);
} else if (isNotificationRoute(currentRoute)) {
await this.fetchNotificationsOnReconnect();
}
};
setConversationLastMessageId = async () => {
const { conversation_id: conversationId } =
this.router.currentRoute.value.params;
if (conversationId) {
await this.store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversationId),
});
}
};
onDisconnect = () => {
this.disconnectTime = new Date();
this.setConversationLastMessageId();
};
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
export default ReconnectService;

View File

@@ -0,0 +1,28 @@
export default class Timer {
constructor(onTick = null) {
this.elapsed = 0;
this.intervalId = null;
this.onTick = onTick;
}
start() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.elapsed = 0;
this.intervalId = setInterval(() => {
this.elapsed += 1;
if (this.onTick) {
this.onTick(this.elapsed);
}
}, 1000);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.elapsed = 0;
}
}

View File

@@ -0,0 +1,178 @@
export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';
return `/app/${path}${stringifiedParams}`;
};
export const conversationUrl = ({
accountId,
activeInbox,
id,
label,
teamId,
conversationType = '',
foldersId,
}) => {
let url = `accounts/${accountId}/conversations/${id}`;
if (activeInbox) {
url = `accounts/${accountId}/inbox/${activeInbox}/conversations/${id}`;
} else if (label) {
url = `accounts/${accountId}/label/${label}/conversations/${id}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}/conversations/${id}`;
} else if (foldersId && foldersId !== 0) {
url = `accounts/${accountId}/custom_view/${foldersId}/conversations/${id}`;
} else if (conversationType === 'mention') {
url = `accounts/${accountId}/mentions/conversations/${id}`;
} else if (conversationType === 'participating') {
url = `accounts/${accountId}/participating/conversations/${id}`;
} else if (conversationType === 'unattended') {
url = `accounts/${accountId}/unattended/conversations/${id}`;
}
return url;
};
export const conversationListPageURL = ({
accountId,
conversationType = '',
inboxId,
label,
teamId,
customViewId,
}) => {
let url = `accounts/${accountId}/dashboard`;
if (label) {
url = `accounts/${accountId}/label/${label}`;
} else if (teamId) {
url = `accounts/${accountId}/team/${teamId}`;
} else if (inboxId) {
url = `accounts/${accountId}/inbox/${inboxId}`;
} else if (customViewId) {
url = `accounts/${accountId}/custom_view/${customViewId}`;
} else if (conversationType) {
const urlMap = {
mention: 'mentions/conversations',
unattended: 'unattended/conversations',
};
url = `accounts/${accountId}/${urlMap[conversationType]}`;
}
return frontendURL(url);
};
export const isValidURL = value => {
/* eslint-disable no-useless-escape */
const URL_REGEX =
/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/gm;
return URL_REGEX.test(value);
};
export const getArticleSearchURL = ({
host,
portalSlug,
pageNumber,
locale,
status,
authorId,
categorySlug,
sort,
query,
}) => {
const queryParams = new URLSearchParams({});
const params = {
page: pageNumber,
locale,
status,
author_id: authorId,
category_slug: categorySlug,
sort,
query,
};
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
queryParams.set(key, value);
}
});
return `${host}/${portalSlug}/articles?${queryParams.toString()}`;
};
export const hasValidAvatarUrl = avatarUrl => {
try {
const { host: avatarUrlHost } = new URL(avatarUrl);
const isFromGravatar = ['www.gravatar.com', 'gravatar'].includes(
avatarUrlHost
);
return avatarUrl && !isFromGravatar;
} catch (error) {
return false;
}
};
export const timeStampAppendedURL = dataUrl => {
const url = new URL(dataUrl);
if (!url.searchParams.has('t')) {
url.searchParams.append('t', Date.now());
}
return url.toString();
};
export const getHostNameFromURL = url => {
try {
return new URL(url).hostname;
} catch (error) {
return null;
}
};
/**
* Extracts filename from a URL
* @param {string} url - The URL to extract filename from
* @returns {string} - The extracted filename or original URL if extraction fails
*/
export const extractFilenameFromUrl = url => {
if (!url || typeof url !== 'string') return url;
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split('/').pop();
return filename || url;
} catch (error) {
// If URL parsing fails, try to extract filename using regex
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
return match ? match[1] : url;
}
};
/**
* Normalizes a comma/newline separated list of domains
* @param {string} domains - The comma/newline separated list of domains
* @returns {string} - The normalized list of domains
* - Converts newlines to commas
* - Trims whitespace
* - Lowercases entries
* - Removes empty values
* - De-duplicates while preserving original order
*/
export const sanitizeAllowedDomains = domains => {
if (!domains) return '';
const tokens = domains
.replace(/\r\n/g, '\n')
.replace(/\s*\n\s*/g, ',')
.split(',')
.map(d => d.trim().toLowerCase())
.filter(d => d.length > 0);
// De-duplicate while preserving order using Set and filter index
const seen = new Set();
const unique = tokens.filter(d => {
if (seen.has(d)) return false;
seen.add(d);
return true;
});
return unique.join(',');
};

View File

@@ -0,0 +1,209 @@
import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
const { isImpersonating } = useImpersonation();
class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) {
const { websocketURL = '' } = window.chatwootConfig || {};
super(app, pubsubToken, websocketURL);
this.CancelTyping = [];
this.events = {
'message.created': this.onMessageCreated,
'message.updated': this.onMessageUpdated,
'conversation.created': this.onConversationCreated,
'conversation.status_changed': this.onStatusChange,
'user:logout': this.onLogout,
'page:reload': this.onReload,
'assignee.changed': this.onAssigneeChanged,
'conversation.typing_on': this.onTypingOn,
'conversation.typing_off': this.onTypingOff,
'conversation.contact_changed': this.onConversationContactChange,
'presence.update': this.onPresenceUpdate,
'contact.deleted': this.onContactDelete,
'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
'notification.updated': this.onNotificationUpdated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
'copilot.message.created': this.onCopilotMessageCreated,
};
}
// eslint-disable-next-line class-methods-use-this
onReconnect = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
};
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => {
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
};
isAValidEvent = data => {
return this.app.$store.getters.getCurrentAccountId === data.account_id;
};
onMessageUpdated = data => {
this.app.$store.dispatch('updateMessage', data);
};
onPresenceUpdate = data => {
if (isImpersonating.value) return;
this.app.$store.dispatch('contacts/updatePresence', data.contacts);
this.app.$store.dispatch('agents/updatePresence', data.users);
this.app.$store.dispatch('setCurrentUserAvailability', data.users);
};
onConversationContactChange = payload => {
const { meta = {}, id: conversationId } = payload;
const { sender } = meta || {};
if (conversationId) {
this.app.$store.dispatch('updateConversationContact', {
conversationId,
...sender,
});
}
};
onAssigneeChanged = payload => {
const { id } = payload;
if (id) {
this.app.$store.dispatch('updateConversation', payload);
}
this.fetchConversationStats();
};
onConversationCreated = data => {
this.app.$store.dispatch('addConversation', data);
this.fetchConversationStats();
};
onConversationRead = data => {
this.app.$store.dispatch('updateConversation', data);
};
// eslint-disable-next-line class-methods-use-this
onLogout = () => AuthAPI.logout();
onMessageCreated = data => {
const {
conversation: { last_activity_at: lastActivityAt },
conversation_id: conversationId,
} = data;
DashboardAudioNotificationHelper.onNewMessage(data);
this.app.$store.dispatch('addMessage', data);
this.app.$store.dispatch('updateConversationLastActivity', {
lastActivityAt,
conversationId,
});
};
// eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload();
onStatusChange = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
onTypingOn = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/create', {
conversationId,
user,
});
this.initTimer({ conversation, user });
};
onTypingOff = ({ conversation, user }) => {
const conversationId = conversation.id;
this.clearTimer(conversationId);
this.app.$store.dispatch('conversationTypingStatus/destroy', {
conversationId,
user,
});
};
onConversationMentioned = data => {
this.app.$store.dispatch('addMentions', data);
};
clearTimer = conversationId => {
const timerEvent = this.CancelTyping[conversationId];
if (timerEvent) {
clearTimeout(timerEvent);
this.CancelTyping[conversationId] = null;
}
};
initTimer = ({ conversation, user }) => {
const conversationId = conversation.id;
// Turn off typing automatically after 30 seconds
this.CancelTyping[conversationId] = setTimeout(() => {
this.onTypingOff({ conversation, user });
}, 30000);
};
// eslint-disable-next-line class-methods-use-this
fetchConversationStats = () => {
emitter.emit('fetch_conversation_stats');
};
onContactDelete = data => {
this.app.$store.dispatch(
'contacts/deleteContactThroughConversations',
data.id
);
this.fetchConversationStats();
};
onContactUpdate = data => {
this.app.$store.dispatch('contacts/updateContact', data);
};
onNotificationCreated = data => {
this.app.$store.dispatch('notifications/addNotification', data);
};
onNotificationDeleted = data => {
this.app.$store.dispatch('notifications/deleteNotification', data);
};
onNotificationUpdated = data => {
this.app.$store.dispatch('notifications/updateNotification', data);
};
onCopilotMessageCreated = data => {
this.app.$store.dispatch('copilotMessages/upsert', data);
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
}
export default {
init(store, pubsubToken) {
return new ActionCableConnector({ $store: store }, pubsubToken);
},
};

View File

@@ -0,0 +1,46 @@
const allElementsString = arr => {
return arr.every(elem => typeof elem === 'string');
};
const allElementsNumbers = arr => {
return arr.every(elem => typeof elem === 'number');
};
const formatArray = params => {
if (params.length <= 0) {
params = [];
} else if (allElementsString(params) || allElementsNumbers(params)) {
params = [...params];
} else {
params = params.map(val => val.id);
}
return params;
};
const generatePayloadForObject = item => {
if (item.action_params.id) {
item.action_params = [item.action_params.id];
} else {
item.action_params = [item.action_params];
}
return item.action_params;
};
const generatePayload = data => {
const actions = JSON.parse(JSON.stringify(data));
let payload = actions.map(item => {
if (Array.isArray(item.action_params)) {
item.action_params = formatArray(item.action_params);
} else if (typeof item.action_params === 'object') {
item.action_params = generatePayloadForObject(item);
} else if (!item.action_params) {
item.action_params = [];
} else {
item.action_params = [item.action_params];
}
return item;
});
return payload;
};
export default generatePayload;

View File

@@ -0,0 +1,51 @@
/**
* Filters and sorts agents by availability status
* @param {Array} agents - List of agents
* @param {string} availability - Availability status to filter by
* @returns {Array} Filtered and sorted list of agents
*/
export const getAgentsByAvailability = (agents, availability) => {
return agents
.filter(agent => agent.availability_status === availability)
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
* Sorts agents by availability status: online, busy, then offline
* @param {Array} agents - List of agents
* @returns {Array} Sorted list of agents
*/
export const getSortedAgentsByAvailability = agents => {
const onlineAgents = getAgentsByAvailability(agents, 'online');
const busyAgents = getAgentsByAvailability(agents, 'busy');
const offlineAgents = getAgentsByAvailability(agents, 'offline');
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
return filteredAgents;
};
/**
* Updates the availability status of the current user based on the current account
* @param {Array} agents - List of agents
* @param {Object} currentUser - Current user object
* @param {number} currentAccountId - ID of the current account
* @returns {Array} Updated list of agents with dynamic presence
*/
// Here we are updating the availability status of the current user dynamically
// based on the current account availability status
export const getAgentsByUpdatedPresence = (
agents,
currentUser,
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
? {
...item,
availability_status: currentUser.accounts.find(
account => account.id === currentAccountId
).availability_status,
}
: item
);
return agentsWithDynamicPresenceUpdate;
};

View File

@@ -0,0 +1,219 @@
const roleMapping = {
0: 'agent',
1: 'administrator',
};
const availabilityMapping = {
0: 'online',
1: 'offline',
2: 'busy',
};
const translationKeys = {
'automationrule:create': `AUDIT_LOGS.AUTOMATION_RULE.ADD`,
'automationrule:update': `AUDIT_LOGS.AUTOMATION_RULE.EDIT`,
'automationrule:destroy': `AUDIT_LOGS.AUTOMATION_RULE.DELETE`,
'webhook:create': `AUDIT_LOGS.WEBHOOK.ADD`,
'webhook:update': `AUDIT_LOGS.WEBHOOK.EDIT`,
'webhook:destroy': `AUDIT_LOGS.WEBHOOK.DELETE`,
'inbox:create': `AUDIT_LOGS.INBOX.ADD`,
'inbox:update': `AUDIT_LOGS.INBOX.EDIT`,
'inbox:destroy': `AUDIT_LOGS.INBOX.DELETE`,
'user:sign_in': `AUDIT_LOGS.USER_ACTION.SIGN_IN`,
'user:sign_out': `AUDIT_LOGS.USER_ACTION.SIGN_OUT`,
'team:create': `AUDIT_LOGS.TEAM.ADD`,
'team:update': `AUDIT_LOGS.TEAM.EDIT`,
'team:destroy': `AUDIT_LOGS.TEAM.DELETE`,
'macro:create': `AUDIT_LOGS.MACRO.ADD`,
'macro:update': `AUDIT_LOGS.MACRO.EDIT`,
'macro:destroy': `AUDIT_LOGS.MACRO.DELETE`,
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
'accountuser:update:deleted': `AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED`,
'inboxmember:create': `AUDIT_LOGS.INBOX_MEMBER.ADD`,
'inboxmember:destroy': `AUDIT_LOGS.INBOX_MEMBER.REMOVE`,
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
'teammember:destroy': `AUDIT_LOGS.TEAM_MEMBER.REMOVE`,
'account:update': `AUDIT_LOGS.ACCOUNT.EDIT`,
'conversation:destroy': `AUDIT_LOGS.CONVERSATION.DELETE`,
};
function extractAttrChange(attrChange) {
if (Array.isArray(attrChange)) {
return attrChange[attrChange.length - 1];
}
return attrChange;
}
export function extractChangedAccountUserValues(auditedChanges) {
let changes = [];
let values = [];
// Check roles
if (auditedChanges.role && auditedChanges.role.length) {
changes.push('role');
values.push(roleMapping[extractAttrChange(auditedChanges.role)]);
}
// Check availability
if (auditedChanges.availability && auditedChanges.availability.length) {
changes.push('availability');
values.push(
availabilityMapping[extractAttrChange(auditedChanges.availability)]
);
}
return { changes, values };
}
function getAgentName(userId, agentList) {
if (userId === null) {
return 'System';
}
const agentName = agentList.find(agent => agent.id === userId)?.name;
// If agent does not exist(removed/deleted), return userId
return agentName || userId;
}
function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
translationPayload.invitee = getAgentName(
auditLogItem.audited_changes.user_id,
agentList
);
const roleKey = auditLogItem.audited_changes.role;
translationPayload.role = roleMapping[roleKey] || 'unknown'; // 'unknown' as a fallback in case an unrecognized key is provided
return translationPayload;
}
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
if (auditLogItem.user_id !== auditLogItem.auditable?.user_id) {
translationPayload.user = getAgentName(
auditLogItem.auditable?.user_id,
agentList
);
}
const accountUserChanges = extractChangedAccountUserValues(
auditLogItem.audited_changes
);
if (accountUserChanges) {
translationPayload.attributes = accountUserChanges.changes;
translationPayload.values = accountUserChanges.values;
}
return translationPayload;
}
function setUserInPayload(auditLogItem, translationPayload, agentList) {
const userIdChange = auditLogItem.audited_changes.user_id;
if (userIdChange && userIdChange !== undefined) {
translationPayload.user = getAgentName(userIdChange, agentList);
}
return translationPayload;
}
function setTeamIdInPayload(auditLogItem, translationPayload) {
if (auditLogItem.audited_changes.team_id) {
translationPayload.team_id = auditLogItem.audited_changes.team_id;
}
return translationPayload;
}
function setInboxIdInPayload(auditLogItem, translationPayload) {
if (auditLogItem.audited_changes.inbox_id) {
translationPayload.inbox_id = auditLogItem.audited_changes.inbox_id;
}
return translationPayload;
}
function handleInboxTeamMember(auditLogItem, translationPayload, agentList) {
if (auditLogItem.audited_changes) {
translationPayload = setUserInPayload(
auditLogItem,
translationPayload,
agentList
);
translationPayload = setTeamIdInPayload(auditLogItem, translationPayload);
translationPayload = setInboxIdInPayload(auditLogItem, translationPayload);
}
return translationPayload;
}
function handleAccountUser(
auditLogItem,
translationPayload,
agentList,
action
) {
if (action === 'create') {
return handleAccountUserCreate(auditLogItem, translationPayload, agentList);
}
if (action === 'update') {
return handleAccountUserUpdate(auditLogItem, translationPayload, agentList);
}
return translationPayload;
}
export function generateTranslationPayload(auditLogItem, agentList) {
let translationPayload = {
agentName: getAgentName(auditLogItem.user_id, agentList),
id: auditLogItem.auditable_id,
};
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
if (auditableType === 'conversation' && action === 'destroy') {
translationPayload.id =
auditLogItem.audited_changes?.display_id || auditLogItem.auditable_id;
}
if (auditableType === 'accountuser') {
translationPayload = handleAccountUser(
auditLogItem,
translationPayload,
agentList,
action
);
}
if (auditableType === 'inboxmember' || auditableType === 'teammember') {
translationPayload = handleInboxTeamMember(
auditLogItem,
translationPayload,
agentList
);
}
return translationPayload;
}
function getAccountUserUpdateSuffix(auditLogItem) {
if (auditLogItem.auditable === null) {
// If the user is deleted, we don't need to check if the user is the same as the auditLogItem.user_id
// Else we can use the deleted translation key
// It doesn't need the agent name
return ':deleted';
}
return auditLogItem.user_id === auditLogItem.auditable.user_id
? ':self'
: ':other';
}
export const generateLogActionKey = auditLogItem => {
const auditableType = auditLogItem.auditable_type.toLowerCase();
const action = auditLogItem.action.toLowerCase();
let logActionKey = `${auditableType}:${action}`;
if (auditableType === 'accountuser' && action === 'update') {
logActionKey += getAccountUserUpdateSuffix(auditLogItem);
}
return translationKeys[logActionKey] || '';
};

View File

@@ -0,0 +1,341 @@
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
DEFAULT_CONVERSATION_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
export const getCustomAttributeInputType = key => {
const customAttributeMap = {
date: 'date',
text: 'plain_text',
list: 'search_select',
checkbox: 'search_select',
};
return customAttributeMap[key] || 'plain_text';
};
export const isACustomAttribute = (customAttributes, key) => {
return customAttributes.find(attr => {
return attr.attribute_key === key;
});
};
export const getCustomAttributeListDropdownValues = (
customAttributes,
type
) => {
return customAttributes
.find(attr => attr.attribute_key === type)
.attribute_values.map(item => {
return {
id: item,
name: item,
};
});
};
export const isCustomAttributeCheckbox = (customAttributes, key) => {
return customAttributes.find(attr => {
return (
attr.attribute_key === key && attr.attribute_display_type === 'checkbox'
);
});
};
export const isCustomAttributeList = (customAttributes, type) => {
return customAttributes.find(attr => {
return (
attr.attribute_key === type && attr.attribute_display_type === 'list'
);
});
};
export const getOperatorTypes = key => {
const operatorMap = {
list: OPERATOR_TYPES_1,
text: OPERATOR_TYPES_3,
number: OPERATOR_TYPES_1,
link: OPERATOR_TYPES_1,
date: OPERATOR_TYPES_4,
checkbox: OPERATOR_TYPES_1,
};
return operatorMap[key] || OPERATOR_TYPES_1;
};
export const generateCustomAttributeTypes = (customAttributes, type) => {
return customAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
inputType: getCustomAttributeInputType(attr.attribute_display_type),
filterOperators: getOperatorTypes(attr.attribute_display_type),
customAttributeType: type,
};
});
};
export const generateConditionOptions = (options, key = 'id') => {
if (!options || !Array.isArray(options)) return [];
return options.map(i => {
return {
id: i[key],
name: i.title,
};
});
};
export const getActionOptions = ({
agents,
teams,
labels,
slaPolicies,
type,
addNoneToListFn,
priorityOptions,
}) => {
const actionsMap = {
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
assign_team: addNoneToListFn ? addNoneToListFn(teams) : teams,
send_email_to_team: teams,
add_label: generateConditionOptions(labels, 'title'),
remove_label: generateConditionOptions(labels, 'title'),
change_priority: priorityOptions,
add_sla: slaPolicies,
};
return actionsMap[type];
};
export const getConditionOptions = ({
agents,
booleanFilterOptions,
campaigns,
contacts,
countries,
customAttributes,
inboxes,
languages,
labels,
statusFilterOptions,
teams,
type,
priorityOptions,
messageTypeOptions,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
}
if (isCustomAttributeList(customAttributes, type)) {
return getCustomAttributeListDropdownValues(customAttributes, type);
}
const conditionFilterMaps = {
status: statusFilterOptions,
assignee_id: agents,
contact: contacts,
inbox_id: inboxes,
team_id: teams,
campaigns: generateConditionOptions(campaigns),
browser_language: languages,
conversation_language: languages,
country_code: countries,
message_type: messageTypeOptions,
priority: priorityOptions,
labels: generateConditionOptions(labels, 'title'),
};
return conditionFilterMaps[type];
};
export const getFileName = (action, files = []) => {
const blobId = action.action_params[0];
if (!blobId) return '';
if (action.action_name === 'send_attachment') {
const file = files.find(item => item.blob_id === blobId);
if (file) return file.filename.toString();
}
return '';
};
export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return structuredClone(DEFAULT_MESSAGE_CREATED_CONDITION);
}
if (
eventName === 'conversation_opened' ||
eventName === 'conversation_resolved'
) {
return structuredClone(DEFAULT_CONVERSATION_CONDITION);
}
return structuredClone(DEFAULT_OTHER_CONDITION);
};
export const getDefaultActions = () => {
return structuredClone(DEFAULT_ACTIONS);
};
export const filterCustomAttributes = customAttributes => {
return customAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
type: attr.attribute_display_type,
};
});
};
export const getStandardAttributeInputType = (automationTypes, event, key) => {
return automationTypes[event].conditions.find(item => item.key === key)
.inputType;
};
export const generateAutomationPayload = payload => {
const automation = JSON.parse(JSON.stringify(payload));
automation.conditions[automation.conditions.length - 1].query_operator = null;
automation.conditions = filterQueryGenerator(automation.conditions).payload;
automation.actions = actionQueryGenerator(automation.actions);
return automation;
};
export const isCustomAttribute = (attrs, key) => {
return attrs.find(attr => attr.key === key);
};
export const generateCustomAttributes = (
// eslint-disable-next-line default-param-last
conversationAttributes = [],
// eslint-disable-next-line default-param-last
contactAttributes = [],
conversationlabel,
contactlabel
) => {
const customAttributes = [];
if (conversationAttributes.length) {
customAttributes.push(
{
key: `conversation_custom_attribute`,
name: conversationlabel,
disabled: true,
},
...conversationAttributes
);
}
if (contactAttributes.length) {
customAttributes.push(
{
key: `contact_custom_attribute`,
name: contactlabel,
disabled: true,
},
...contactAttributes
);
}
return customAttributes;
};
/**
* Get attributes for a given key from automation types.
* @param {Object} automationTypes - Object containing automation types.
* @param {string} key - The key to get attributes for.
* @returns {Array} Array of condition objects for the given key.
*/
export const getAttributes = (automationTypes, key) => {
return automationTypes[key].conditions;
};
/**
* Get the automation type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the automation type for.
* @returns {Object} The automation type object.
*/
export const getAutomationType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
condition => condition.key === key
);
};
/**
* Get the input type for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the input type for.
* @returns {string} The input type.
*/
export const getInputType = (
allCustomAttributes,
automationTypes,
automation,
key
) => {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getCustomAttributeInputType(customAttribute.attribute_display_type);
}
const type = getAutomationType(automationTypes, automation, key);
return type.inputType;
};
/**
* Get operators for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} mode - The mode ('edit' or other).
* @param {string} key - The key to get operators for.
* @returns {Array} Array of operators.
*/
export const getOperators = (
allCustomAttributes,
automationTypes,
automation,
mode,
key
) => {
if (mode === 'edit') {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getOperatorTypes(customAttribute.attribute_display_type);
}
}
const type = getAutomationType(automationTypes, automation, key);
return type.filterOperators;
};
/**
* Get the custom attribute type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the custom attribute type for.
* @returns {string} The custom attribute type.
*/
export const getCustomAttributeType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
i => i.key === key
).customAttributeType;
};
/**
* Determine if an action input should be shown.
* @param {Array} automationActionTypes - Array of automation action type objects.
* @param {string} action - The action to check.
* @returns {boolean} True if the action input should be shown, false otherwise.
*/
export const showActionInput = (automationActionTypes, action) => {
if (action === 'send_email_to_team' || action === 'send_message')
return false;
const type = automationActionTypes.find(i => i.key === action).inputType;
return !!type;
};

View File

@@ -0,0 +1,92 @@
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import {
CMD_MUTE_CONVERSATION,
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import {
ICON_MUTE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/icons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const OPEN_CONVERSATION_ACTIONS = [
{
id: 'resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => emitter.emit(CMD_RESOLVE_CONVERSATION),
},
];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
};
export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_SNOOZE_CONVERSATION,
'snooze_conversation',
'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
),
];
export const RESOLVED_CONVERSATION_ACTIONS = [
{
id: 'reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION,
handler: () => emitter.emit(CMD_REOPEN_CONVERSATION),
},
];
export const SEND_TRANSCRIPT_ACTION = {
id: 'send_transcript',
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT,
handler: () => emitter.emit(CMD_SEND_TRANSCRIPT),
};
export const UNMUTE_ACTION = {
id: 'unmute_conversation',
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION,
handler: () => emitter.emit(CMD_UNMUTE_CONVERSATION),
};
export const MUTE_ACTION = {
id: 'mute_conversation',
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION,
handler: () => emitter.emit(CMD_MUTE_CONVERSATION),
};

View File

@@ -0,0 +1,28 @@
// General Actions - Switch conversation tabs
export const CMD_SWITCH_TAB = 'CMD_SWITCH_TAB';
// General Actions - Switch conversation status
export const CMD_SWITCH_STATUS = 'CMD_SWITCH_STATUS';
// Conversation Actions
export const CMD_MUTE_CONVERSATION = 'CMD_MUTE_CONVERSATION';
export const CMD_UNMUTE_CONVERSATION = 'CMD_UNMUTE_CONVERSATION';
export const CMD_SEND_TRANSCRIPT = 'CMD_SEND_TRANSCRIPT';
export const CMD_TOGGLE_CONTACT_SIDEBAR = 'CMD_TOGGLE_CONTACT_SIDEBAR';
// Status Commands
export const CMD_REOPEN_CONVERSATION = 'CMD_REOPEN_CONVERSATION';
export const CMD_RESOLVE_CONVERSATION = 'CMD_RESOLVE_CONVERSATION';
export const CMD_SNOOZE_CONVERSATION = 'CMD_SNOOZE_CONVERSATION';
export const CMD_AI_ASSIST = 'CMD_AI_ASSIST';
// Bulk Actions
export const CMD_BULK_ACTION_SNOOZE_CONVERSATION =
'CMD_BULK_ACTION_SNOOZE_CONVERSATION';
export const CMD_BULK_ACTION_REOPEN_CONVERSATION =
'CMD_BULK_ACTION_REOPEN_CONVERSATION';
export const CMD_BULK_ACTION_RESOLVE_CONVERSATION =
'CMD_BULK_ACTION_RESOLVE_CONVERSATION';
// Inbox Commands (Notifications)
export const CMD_SNOOZE_NOTIFICATION = 'CMD_SNOOZE_NOTIFICATION';

View File

@@ -0,0 +1,75 @@
export const ICON_ADD_LABEL = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465Zm0 1.5h-5.465c-.465 0-.91.185-1.239.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.477 0l8.5-8.503a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75ZM17 5.502a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z" fill="currentColor"/></svg>`;
export const ICON_ASSIGN_AGENT = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 12a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm-5.478 2a6.474 6.474 0 0 0-.708 1.5h-7.06a.75.75 0 0 0-.75.75v.907c0 .656.286 1.279.783 1.706C5.545 19.945 7.44 20.501 10 20.501c.599 0 1.162-.03 1.688-.091.25.5.563.964.93 1.38-.803.141-1.676.21-2.618.21-2.89 0-5.128-.656-6.691-2a3.75 3.75 0 0 1-1.305-2.843v-.907A2.25 2.25 0 0 1 4.254 14h7.768Zm4.697.588-.069.058-2.515 2.517-.041.05-.035.058-.032.078-.012.043-.01.086.003.088.019.085.032.078.025.042.05.066 2.516 2.516a.5.5 0 0 0 .765-.638l-.058-.069L15.711 18h4.79a.5.5 0 0 0 .491-.41L21 17.5a.5.5 0 0 0-.41-.492L20.5 17h-4.789l1.646-1.647a.5.5 0 0 0 .058-.637l-.058-.07a.5.5 0 0 0-.638-.058ZM10 2.004a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="currentColor"/></svg>`;
export const ICON_MUTE_CONVERSATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.92 3.316c.806-.717 2.08-.145 2.08.934v15.496c0 1.078-1.274 1.65-2.08.934l-4.492-3.994a.75.75 0 0 0-.498-.19H4.25A2.25 2.25 0 0 1 2 14.247V9.75a2.25 2.25 0 0 1 2.25-2.25h3.68a.75.75 0 0 0 .498-.19l4.491-3.993Zm.58 1.49L9.425 8.43A2.25 2.25 0 0 1 7.93 9H4.25a.75.75 0 0 0-.75.75v4.497c0 .415.336.75.75.75h3.68a2.25 2.25 0 0 1 1.495.57l4.075 3.623V4.807ZM16.22 9.22a.75.75 0 0 1 1.06 0L19 10.94l1.72-1.72a.75.75 0 1 1 1.06 1.06L20.06 12l1.72 1.72a.75.75 0 1 1-1.06 1.06L19 13.06l-1.72 1.72a.75.75 0 1 1-1.06-1.06L17.94 12l-1.72-1.72a.75.75 0 0 1 0-1.06Z" fill="currentColor"/></svg>`;
export const ICON_UNMUTE_CONVERSATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M15 4.25c0-1.079-1.274-1.65-2.08-.934L8.427 7.309a.75.75 0 0 1-.498.19H4.25A2.25 2.25 0 0 0 2 9.749v4.497a2.25 2.25 0 0 0 2.25 2.25h3.68a.75.75 0 0 1 .498.19l4.491 3.994c.806.716 2.081.144 2.081-.934V4.25ZM9.425 8.43 13.5 4.807v14.382l-4.075-3.624a2.25 2.25 0 0 0-1.495-.569H4.25a.75.75 0 0 1-.75-.75V9.75a.75.75 0 0 1 .75-.75h3.68a2.25 2.25 0 0 0 1.495-.569ZM18.992 5.897a.75.75 0 0 1 1.049.157A9.959 9.959 0 0 1 22 12a9.96 9.96 0 0 1-1.96 5.946.75.75 0 0 1-1.205-.892A8.459 8.459 0 0 0 20.5 12a8.459 8.459 0 0 0-1.665-5.054.75.75 0 0 1 .157-1.049Z" fill="#212121"/><path d="M17.143 8.37a.75.75 0 0 1 1.017.302c.536.99.84 2.125.84 3.328a6.973 6.973 0 0 1-.84 3.328.75.75 0 0 1-1.32-.714c.42-.777.66-1.666.66-2.614s-.24-1.837-.66-2.614a.75.75 0 0 1 .303-1.017Z" fill="currentColor"/></svg>`;
export const ICON_REMOVE_LABEL = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-.026.026a6.473 6.473 0 0 0-1.43-.692l.395-.395a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75h-5.466c-.464 0-.91.185-1.238.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.33.13c.165.487.386.947.654 1.374a3.256 3.256 0 0 1-4.043-.442L3.489 16.06a3.25 3.25 0 0 1-.004-4.596l8.5-8.51a3.25 3.25 0 0 1 2.3-.953h5.465ZM17 5.502a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM17.5 23a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11Zm-2.354-7.854a.5.5 0 0 1 .708 0l1.646 1.647 1.646-1.647a.5.5 0 0 1 .708.708L18.207 17.5l1.647 1.646a.5.5 0 0 1-.708.708L17.5 18.207l-1.646 1.647a.5.5 0 0 1-.708-.708l1.647-1.646-1.647-1.646a.5.5 0 0 1 0-.708Z" fill="currentColor"/></svg>`;
export const ICON_REOPEN_CONVERSATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.25 2a.75.75 0 0 0-.743.648l-.007.102v5.69l-4.574-4.56a6.41 6.41 0 0 0-8.878-.179l-.186.18a6.41 6.41 0 0 0 0 9.063l8.845 8.84a.75.75 0 0 0 1.06-1.062l-8.845-8.838a4.91 4.91 0 0 1 6.766-7.112l.178.17L17.438 9.5H11.75a.75.75 0 0 0-.743.648L11 10.25c0 .38.282.694.648.743l.102.007h7.5a.75.75 0 0 0 .743-.648L20 10.25v-7.5a.75.75 0 0 0-.75-.75Z" fill="currentColor"/></svg>`;
export const ICON_RESOLVE_CONVERSATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2Zm0 1.5a8.5 8.5 0 1 0 0 17 8.5 8.5 0 0 0 0-17Zm-1.25 9.94 4.47-4.47a.75.75 0 0 1 1.133.976l-.073.084-5 5a.75.75 0 0 1-.976.073l-.084-.073-2.5-2.5a.75.75 0 0 1 .976-1.133l.084.073 1.97 1.97 4.47-4.47-4.47 4.47Z" fill="currentColor"/></svg>`;
export const ICON_SEND_TRANSCRIPT = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 11.5a.75.75 0 0 1 .743.648l.007.102v5a4.75 4.75 0 0 1-4.533 4.745L15.75 22h-7.5c-.98 0-1.813-.626-2.122-1.5h9.622l.184-.005a3.25 3.25 0 0 0 3.06-3.06L19 17.25v-5a.75.75 0 0 1 .75-.75Zm-2.5-2a.75.75 0 0 1 .743.648l.007.102v7a2.25 2.25 0 0 1-2.096 2.245l-.154.005h-10a2.25 2.25 0 0 1-2.245-2.096L3.5 17.25v-7a.75.75 0 0 1 1.493-.102L5 10.25v7c0 .38.282.694.648.743L5.75 18h10a.75.75 0 0 0 .743-.648l.007-.102v-7a.75.75 0 0 1 .75-.75ZM6.218 6.216l3.998-3.996a.75.75 0 0 1 .976-.073l.084.072 4.004 3.997a.75.75 0 0 1-.976 1.134l-.084-.073-2.72-2.714v9.692a.75.75 0 0 1-.648.743l-.102.007a.75.75 0 0 1-.743-.648L10 14.255V4.556L7.279 7.277a.75.75 0 0 1-.977.072l-.084-.072a.75.75 0 0 1-.072-.977l.072-.084 3.998-3.996-3.998 3.996Z" fill="currentColor"/></svg>`;
export const ICON_SNOOZE_CONVERSATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2c5.523 0 10 4.478 10 10s-4.477 10-10 10S2 17.522 2 12S6.477 2 12 2Zm0 1.667c-4.595 0-8.333 3.738-8.333 8.333c0 4.595 3.738 8.333 8.333 8.333c4.595 0 8.333-3.738 8.333-8.333c0-4.595-3.738-8.333-8.333-8.333ZM11.25 6a.75.75 0 0 1 .743.648L12 6.75V12h3.25a.75.75 0 0 1 .102 1.493l-.102.007h-4a.75.75 0 0 1-.743-.648l-.007-.102v-6a.75.75 0 0 1 .75-.75Z" fill="currentColor"/></svg>`;
export const ICON_SNOOZE_UNTIL_NEXT_WEEK = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.75 7a.75.75 0 0 0-.75.75v4c0 .414.336.75.75.75h8.5a.75.75 0 0 0 .75-.75v-4a.75.75 0 0 0-.75-.75h-8.5Zm.75 4V8.5h7V11h-7Z" fill="currentColor"/><path d="M17.75 21A3.25 3.25 0 0 0 21 17.75V6.25A3.25 3.25 0 0 0 17.75 3H6.25A3.25 3.25 0 0 0 3 6.25v11.5A3.25 3.25 0 0 0 6.25 21h11.5ZM19.5 6.25v11.5a1.75 1.75 0 0 1-1.75 1.75H6.25a1.75 1.75 0 0 1-1.75-1.75V6.25c0-.966.784-1.75 1.75-1.75h11.5c.966 0 1.75.784 1.75 1.75Z" fill="currentColor"/></svg>`;
export const ICON_SNOOZE_UNTIL_TOMORRROW = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 8.744C7.847 8.362 8.415 8 9.25 8c1.152 0 1.894.792 2.155 1.661.253.847.1 1.895-.62 2.618a8.092 8.092 0 0 1-.793.67l-.04.031c-.28.216-.53.412-.75.63-.255.256-.464.535-.585.89h2.133a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1-.75-.75c0-1.247.524-2.083 1.144-2.701.296-.296.618-.545.89-.756l.003-.002c.286-.221.508-.393.685-.57.272-.274.367-.725.246-1.13-.115-.381-.37-.591-.718-.591-.353 0-.535.137-.64.253a.843.843 0 0 0-.148.229v.003a.75.75 0 0 1-1.428-.462l.035-.096a2.343 2.343 0 0 1 .43-.683ZM13.25 8a.75.75 0 0 1 .75.75v2.75h1.5V8.75a.75.75 0 0 1 1.5 0v6.47a.75.75 0 0 1-1.5 0V13h-2.25a.75.75 0 0 1-.75-.75v-3.5a.75.75 0 0 1 .75-.75Z" fill="currentColor"/><path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12s4.477 10 10 10 10-4.477 10-10ZM3.5 12a8.5 8.5 0 1 1 17 0 8.5 8.5 0 0 1-17 0Z" fill="currentColor"/></svg>`;
export const ICON_CONVERSATION_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M10.55 2.532a2.25 2.25 0 0 1 2.9 0l6.75 5.692c.507.428.8 1.057.8 1.72v9.803a1.75 1.75 0 0 1-1.75 1.75h-3.5a1.75 1.75 0 0 1-1.75-1.75v-5.5a.25.25 0 0 0-.25-.25h-3.5a.25.25 0 0 0-.25.25v5.5a1.75 1.75 0 0 1-1.75 1.75h-3.5A1.75 1.75 0 0 1 3 19.747V9.944c0-.663.293-1.292.8-1.72l6.75-5.692zm1.933 1.147a.75.75 0 0 0-.966 0L4.767 9.37a.75.75 0 0 0-.267.573v9.803c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25v-5.5c0-.967.784-1.75 1.75-1.75h3.5c.966 0 1.75.783 1.75 1.75v5.5c0 .138.112.25.25.25h3.5a.25.25 0 0 0 .25-.25V9.944a.75.75 0 0 0-.267-.573l-6.75-5.692z" fill="currentColor"></path></g></svg>`;
export const ICON_CONTACT_DASHBOARD = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M17.754 14a2.249 2.249 0 0 1 2.25 2.249v.575c0 .894-.32 1.76-.902 2.438c-1.57 1.834-3.957 2.739-7.102 2.739c-3.146 0-5.532-.905-7.098-2.74a3.75 3.75 0 0 1-.898-2.435v-.577a2.249 2.249 0 0 1 2.249-2.25h11.501zm0 1.5H6.253a.749.749 0 0 0-.75.749v.577c0 .536.192 1.054.54 1.461c1.253 1.468 3.219 2.214 5.957 2.214s4.706-.746 5.962-2.214a2.25 2.25 0 0 0 .541-1.463v-.575a.749.749 0 0 0-.749-.75zM12 2.004a5 5 0 1 1 0 10a5 5 0 0 1 0-10zm0 1.5a3.5 3.5 0 1 0 0 7a3.5 3.5 0 0 0 0-7z" fill="currentColor"></path></g></svg>`;
export const ICON_REPORTS_OVERVIEW = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M16.749 2h4.554l.1.014l.099.028l.06.026c.08.034.153.085.219.15l.04.044l.044.057l.054.09l.039.09l.019.064l.014.064l.009.095v4.532a.75.75 0 0 1-1.493.102l-.007-.102V4.559l-6.44 6.44a.75.75 0 0 1-.976.073L13 11L9.97 8.09l-5.69 5.689a.75.75 0 0 1-1.133-.977l.073-.084l6.22-6.22a.75.75 0 0 1 .976-.072l.084.072l3.03 2.91L19.438 3.5h-2.69a.75.75 0 0 1-.742-.648l-.007-.102a.75.75 0 0 1 .648-.743L16.75 2zM3.75 17a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5a.75.75 0 0 1 .75-.75zm5.75-3.25a.75.75 0 0 0-1.5 0v7.5a.75.75 0 0 0 1.5 0v-7.5zM13.75 15a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75zm5.75-4.25a.75.75 0 0 0-1.5 0v10.5a.75.75 0 0 0 1.5 0v-10.5z" fill="currentColor"></path></g></svg>`;
export const ICON_CONVERSATION_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10a9.96 9.96 0 0 1-4.587-1.112l-3.826 1.067a1.25 1.25 0 0 1-1.54-1.54l1.068-3.823A9.96 9.96 0 0 1 2 12C2 6.477 6.477 2 12 2Zm0 1.5A8.5 8.5 0 0 0 3.5 12c0 1.47.373 2.883 1.073 4.137l.15.27-1.112 3.984 3.987-1.112.27.15A8.5 8.5 0 1 0 12 3.5ZM8.75 13h4.498a.75.75 0 0 1 .102 1.493l-.102.007H8.75a.75.75 0 0 1-.102-1.493L8.75 13h4.498H8.75Zm0-3.5h6.505a.75.75 0 0 1 .101 1.493l-.101.007H8.75a.75.75 0 0 1-.102-1.493L8.75 9.5h6.505H8.75Z" fill="currentColor"/></svg>`;
export const ICON_AGENT_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M4 13.999L13 14a2 2 0 0 1 1.995 1.85L15 16v1.5C14.999 21 11.284 22 8.5 22c-2.722 0-6.335-.956-6.495-4.27L2 17.5v-1.501c0-1.054.816-1.918 1.85-1.995L4 14zM15.22 14H20c1.054 0 1.918.816 1.994 1.85L22 16v1c-.001 3.062-2.858 4-5 4a7.16 7.16 0 0 1-2.14-.322c.336-.386.607-.827.802-1.327A6.19 6.19 0 0 0 17 19.5l.267-.006c.985-.043 3.086-.363 3.226-2.289L20.5 17v-1a.501.501 0 0 0-.41-.492L20 15.5h-4.051a2.957 2.957 0 0 0-.595-1.34L15.22 14H20h-4.78zM4 15.499l-.1.01a.51.51 0 0 0-.254.136a.506.506 0 0 0-.136.253l-.01.101V17.5c0 1.009.45 1.722 1.417 2.242c.826.445 2.003.714 3.266.753l.317.005l.317-.005c1.263-.039 2.439-.308 3.266-.753c.906-.488 1.359-1.145 1.412-2.057l.005-.186V16a.501.501 0 0 0-.41-.492L13 15.5l-9-.001zM8.5 3a4.5 4.5 0 1 1 0 9a4.5 4.5 0 0 1 0-9zm9 2a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7zm-9-.5c-1.654 0-3 1.346-3 3s1.346 3 3 3s3-1.346 3-3s-1.346-3-3-3zm9 2c-1.103 0-2 .897-2 2s.897 2 2 2s2-.897 2-2s-.897-2-2-2z" fill="currentColor"></path></g></svg>`;
export const ICON_LABEL_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465zm0 1.5h-5.465c-.465 0-.91.185-1.239.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.477 0l8.5-8.503a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75zM17 5.502a1.5 1.5 0 1 1 0 3a1.5 1.5 0 0 1 0-3z" fill="currentColor"></path></g></svg>`;
export const ICON_INBOX_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5h-11.5zM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25v-3.25zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5z" fill="currentColor"></path></g></svg>`;
export const ICON_TEAM_REPORTS = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" preserveAspectRatio="xMidYMid meet" viewBox="0 0 24 24"><g fill="none"><path d="M14.75 15c.966 0 1.75.784 1.75 1.75l-.001.962c.117 2.19-1.511 3.297-4.432 3.297c-2.91 0-4.567-1.09-4.567-3.259v-1c0-.966.784-1.75 1.75-1.75h5.5zm0 1.5h-5.5a.25.25 0 0 0-.25.25v1c0 1.176.887 1.759 3.067 1.759c2.168 0 2.995-.564 2.933-1.757V16.75a.25.25 0 0 0-.25-.25zm-11-6.5h4.376a4.007 4.007 0 0 0-.095 1.5H3.75a.25.25 0 0 0-.25.25v1c0 1.176.887 1.759 3.067 1.759c.462 0 .863-.026 1.207-.077a2.743 2.743 0 0 0-1.173 1.576l-.034.001C3.657 16.009 2 14.919 2 12.75v-1c0-.966.784-1.75 1.75-1.75zm16.5 0c.966 0 1.75.784 1.75 1.75l-.001.962c.117 2.19-1.511 3.297-4.432 3.297l-.169-.002a2.755 2.755 0 0 0-1.218-1.606c.387.072.847.108 1.387.108c2.168 0 2.995-.564 2.933-1.757V11.75a.25.25 0 0 0-.25-.25h-4.28a4.05 4.05 0 0 0-.096-1.5h4.376zM12 8a3 3 0 1 1 0 6a3 3 0 0 1 0-6zm0 1.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3zM6.5 3a3 3 0 1 1 0 6a3 3 0 0 1 0-6zm11 0a3 3 0 1 1 0 6a3 3 0 0 1 0-6zm-11 1.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3zm11 0a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3z" fill="currentColor"></path></g></svg>`;
export const ICON_ASSIGN_TEAM = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.5 12a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Zm0 2-.09.007a.5.5 0 0 0-.402.402L17 14.5V17L14.498 17l-.09.008a.5.5 0 0 0-.402.402l-.008.09.008.09a.5.5 0 0 0 .402.402l.09.008H17v2.503l.008.09a.5.5 0 0 0 .402.402l.09.008.09-.008a.5.5 0 0 0 .402-.402l.008-.09V18l2.504.001.09-.008a.5.5 0 0 0 .402-.402l.008-.09-.008-.09a.5.5 0 0 0-.403-.402l-.09-.008H18v-2.5l-.008-.09a.5.5 0 0 0-.402-.403L17.5 14Zm-3.246-4c.835 0 1.563.454 1.951 1.13a6.44 6.44 0 0 0-1.518.509.736.736 0 0 0-.433-.139H9.752a.75.75 0 0 0-.75.75v4.249c0 1.41.974 2.594 2.286 2.915a6.42 6.42 0 0 0 .735 1.587l-.02-.001a4.501 4.501 0 0 1-4.501-4.501V12.25A2.25 2.25 0 0 1 9.752 10h4.502Zm-6.848 0a3.243 3.243 0 0 0-.817 1.5H4.25a.75.75 0 0 0-.75.75v2.749a2.501 2.501 0 0 0 3.082 2.433c.085.504.24.985.453 1.432A4.001 4.001 0 0 1 2 14.999V12.25a2.25 2.25 0 0 1 2.096-2.245L4.25 10h3.156Zm12.344 0A2.25 2.25 0 0 1 22 12.25v.56A6.478 6.478 0 0 0 17.5 11l-.245.005A3.21 3.21 0 0 0 16.6 10h3.15ZM18.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5ZM12 3a3 3 0 1 1 0 6 3 3 0 0 1 0-6ZM5.5 4a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5Zm13 1.5a1 1 0 1 0 0 2 1 1 0 0 0 0-2Zm-6.5-1a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm-6.5 1a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z" fill="currentColor"/></svg>`;
export const ICON_NOTIFICATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 1.996a7.49 7.49 0 0 1 7.496 7.25l.004.25v4.097l1.38 3.156a1.25 1.25 0 0 1-1.145 1.75L15 18.502a3 3 0 0 1-5.995.177L9 18.499H4.275a1.251 1.251 0 0 1-1.147-1.747L4.5 13.594V9.496c0-4.155 3.352-7.5 7.5-7.5ZM13.5 18.5l-3 .002a1.5 1.5 0 0 0 2.993.145l.006-.147ZM12 3.496c-3.32 0-6 2.674-6 6v4.41L4.656 17h14.697L18 13.907V9.509l-.004-.225A5.988 5.988 0 0 0 12 3.496Z" fill="currentColor"/></svg>`;
export const ICON_USER_PROFILE = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M10.125 13.995a2.737 2.737 0 0 0-.617 1.5h-5.26a.749.749 0 0 0-.748.75v.577c0 .536.191 1.054.539 1.461 1.177 1.379 2.984 2.12 5.469 2.205.049.57.273 1.09.617 1.508h-.129c-3.145 0-5.531-.905-7.098-2.739A3.75 3.75 0 0 1 2 16.822v-.578c0-1.19.925-2.164 2.095-2.243l.154-.006h5.876Zm4.621-2.5h3c.648 0 1.18.492 1.244 1.123l.007.127-.001 1.25h1.25c.967 0 1.75.784 1.75 1.75v4.5a1.75 1.75 0 0 1-1.75 1.75h-8a1.75 1.75 0 0 1-1.75-1.75v-4.5c0-.966.784-1.75 1.75-1.75h1.25v-1.25c0-.647.492-1.18 1.123-1.243l.127-.007h3-3Zm5.5 4h-8a.25.25 0 0 0-.25.25v4.5c0 .138.112.25.25.25h8a.25.25 0 0 0 .25-.25v-4.5a.25.25 0 0 0-.25-.25Zm-2.75-2.5h-2.5v1h2.5v-1ZM9.997 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Zm0 1.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="currentColor"/></svg>`;
export const ICON_CANNED_RESPONSE = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M21 7.511a3.247 3.247 0 0 1 1.5 2.739v6c0 2.9-2.35 5.25-5.25 5.25h-9A3.247 3.247 0 0 1 5.511 20H17.25A3.75 3.75 0 0 0 21 16.25V7.511ZM5.25 4h11.5a3.25 3.25 0 0 1 3.245 3.066L20 7.25v8.5a3.25 3.25 0 0 1-3.066 3.245L16.75 19H5.25a3.25 3.25 0 0 1-3.245-3.066L2 15.75v-8.5a3.25 3.25 0 0 1 3.066-3.245L5.25 4ZM18.5 8.899l-7.15 3.765a.75.75 0 0 1-.603.042l-.096-.042L3.5 8.9v6.85a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V8.899ZM16.75 5.5H5.25a1.75 1.75 0 0 0-1.744 1.606l-.004.1L11 11.152l7.5-3.947A1.75 1.75 0 0 0 16.75 5.5Z" fill="currentColor"/></svg>`;
export const ICON_LABELS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19.75 2A2.25 2.25 0 0 1 22 4.25v5.462a3.25 3.25 0 0 1-.952 2.298l-8.5 8.503a3.255 3.255 0 0 1-4.597.001L3.489 16.06a3.25 3.25 0 0 1-.003-4.596l8.5-8.51A3.25 3.25 0 0 1 14.284 2h5.465Zm0 1.5h-5.465c-.465 0-.91.185-1.239.513l-8.512 8.523a1.75 1.75 0 0 0 .015 2.462l4.461 4.454a1.755 1.755 0 0 0 2.477 0l8.5-8.503a1.75 1.75 0 0 0 .513-1.237V4.25a.75.75 0 0 0-.75-.75ZM17 5.502a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z" fill="currentColor"/></svg>`;
export const ICON_ACCOUNT_SETTINGS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M8.75 3h6.5a.75.75 0 0 1 .743.648L16 3.75V7h1.75A3.25 3.25 0 0 1 21 10.25v6.5A3.25 3.25 0 0 1 17.75 20H6.25A3.25 3.25 0 0 1 3 16.75v-6.5A3.25 3.25 0 0 1 6.25 7H8V3.75a.75.75 0 0 1 .648-.743L8.75 3h6.5-6.5Zm9 5.5H6.25a1.75 1.75 0 0 0-1.75 1.75v6.5c0 .966.784 1.75 1.75 1.75h11.5a1.75 1.75 0 0 0 1.75-1.75v-6.5a1.75 1.75 0 0 0-1.75-1.75Zm-3.25-4h-5V7h5V4.5Z" fill="currentColor"/></svg>`;
export const ICON_INBOXES = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.25 3h11.5a3.25 3.25 0 0 1 3.245 3.066L21 6.25v11.5a3.25 3.25 0 0 1-3.066 3.245L17.75 21H6.25a3.25 3.25 0 0 1-3.245-3.066L3 17.75V6.25a3.25 3.25 0 0 1 3.066-3.245L6.25 3h11.5-11.5ZM4.5 14.5v3.25a1.75 1.75 0 0 0 1.606 1.744l.144.006h11.5a1.75 1.75 0 0 0 1.744-1.607l.006-.143V14.5h-3.825a3.752 3.752 0 0 1-3.475 2.995l-.2.005a3.752 3.752 0 0 1-3.632-2.812l-.043-.188H4.5v3.25-3.25Zm13.25-10H6.25a1.75 1.75 0 0 0-1.744 1.606L4.5 6.25V13H9a.75.75 0 0 1 .743.648l.007.102a2.25 2.25 0 0 0 4.495.154l.005-.154a.75.75 0 0 1 .648-.743L15 13h4.5V6.25a1.75 1.75 0 0 0-1.607-1.744L17.75 4.5Z" fill="currentColor"/></svg>`;
export const ICON_APPS = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m18.492 2.33 3.179 3.179a2.25 2.25 0 0 1 0 3.182l-2.584 2.584A2.25 2.25 0 0 1 21 13.5v5.25A2.25 2.25 0 0 1 18.75 21H5.25A2.25 2.25 0 0 1 3 18.75V5.25A2.25 2.25 0 0 1 5.25 3h5.25a2.25 2.25 0 0 1 2.225 1.915L15.31 2.33a2.25 2.25 0 0 1 3.182 0ZM4.5 18.75c0 .414.336.75.75.75l5.999-.001.001-6.75H4.5v6Zm8.249.749h6.001a.75.75 0 0 0 .75-.75V13.5a.75.75 0 0 0-.75-.75h-6.001v6.75Zm-2.249-15H5.25a.75.75 0 0 0-.75.75v6h6.75v-6a.75.75 0 0 0-.75-.75Zm2.25 4.81v1.94h1.94l-1.94-1.94Zm3.62-5.918-3.178 3.178a.75.75 0 0 0 0 1.061l3.179 3.179a.75.75 0 0 0 1.06 0l3.18-3.179a.75.75 0 0 0 0-1.06l-3.18-3.18a.75.75 0 0 0-1.06 0Z" fill="currentColor"/></svg>`;
export const ICON_ASSIGN_PRIORITY = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 20 20"><path fill="currentColor" d="M9.562 3.262a.5.5 0 0 1 .879 0l6.5 12a.5.5 0 0 1-.44.739H3.5a.5.5 0 0 1-.44-.739l6.503-12Zm1.758-.477c-.567-1.047-2.07-1.047-2.638 0L2.18 14.786a1.5 1.5 0 0 0 1.32 2.215h13.002a1.5 1.5 0 0 0 1.319-2.215l-6.5-12ZM10.5 7.5a.5.5 0 1 0-1 0v4a.5.5 0 0 0 1 0v-4Zm.25 6.25a.75.75 0 1 1-1.5 0a.75.75 0 0 1 1.5 0Z"/></svg>`;
export const ICON_PRIORITY_URGENT = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#FFEBEE"/>
<path d="M8 8.5C8 7.94772 8.44772 7.5 9 7.5C9.55228 7.5 10 7.94772 10 8.5V13C10 13.5523 9.55228 14 9 14C8.44772 14 8 13.5523 8 13V8.5Z" fill="#FF382D"/>
<path d="M8 15.5C8 14.9477 8.44772 14.5 9 14.5C9.55228 14.5 10 14.9477 10 15.5C10 16.0523 9.55228 16.5 9 16.5C8.44772 16.5 8 16.0523 8 15.5Z" fill="#FF382D"/>
<path d="M11 8.5C11 7.94772 11.4477 7.5 12 7.5C12.5523 7.5 13 7.94772 13 8.5V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V8.5Z" fill="#FF382D"/>
<path d="M11 15.5C11 14.9477 11.4477 14.5 12 14.5C12.5523 14.5 13 14.9477 13 15.5C13 16.0523 12.5523 16.5 12 16.5C11.4477 16.5 11 16.0523 11 15.5Z" fill="#FF382D"/>
<path d="M14 8.5C14 7.94772 14.4477 7.5 15 7.5C15.5523 7.5 16 7.94772 16 8.5V13C16 13.5523 15.5523 14 15 14C14.4477 14 14 13.5523 14 13V8.5Z" fill="#FF382D"/>
<path d="M14 15.5C14 14.9477 14.4477 14.5 15 14.5C15.5523 14.5 16 14.9477 16 15.5C16 16.0523 15.5523 16.5 15 16.5C14.4477 16.5 14 16.0523 14 15.5Z" fill="#FF382D"/>
</svg>
`;
export const ICON_PRIORITY_HIGH = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M9.7642 8L9.62358 14.1619H8.25142L8.11506 8H9.7642ZM8.9375 16.821C8.67898 16.821 8.45739 16.7301 8.27273 16.5483C8.09091 16.3665 8 16.1449 8 15.8835C8 15.6278 8.09091 15.4091 8.27273 15.2273C8.45739 15.0455 8.67898 14.9545 8.9375 14.9545C9.19034 14.9545 9.40909 15.0455 9.59375 15.2273C9.78125 15.4091 9.875 15.6278 9.875 15.8835C9.875 16.0568 9.83097 16.2145 9.7429 16.3565C9.65767 16.4986 9.54403 16.6122 9.40199 16.6974C9.26278 16.7798 9.10795 16.821 8.9375 16.821Z" fill="#446888"/>
<path d="M13.1073 8L12.9667 14.1619H11.5945L11.4582 8H13.1073ZM12.2806 16.821C12.0221 16.821 11.8005 16.7301 11.6159 16.5483C11.434 16.3665 11.3431 16.1449 11.3431 15.8835C11.3431 15.6278 11.434 15.4091 11.6159 15.2273C11.8005 15.0455 12.0221 14.9545 12.2806 14.9545C12.5335 14.9545 12.7522 15.0455 12.9369 15.2273C13.1244 15.4091 13.2181 15.6278 13.2181 15.8835C13.2181 16.0568 13.1741 16.2145 13.086 16.3565C13.0008 16.4986 12.8872 16.6122 12.7451 16.6974C12.6059 16.7798 12.4511 16.821 12.2806 16.821Z" fill="#446888"/>
<path d="M16.4505 8L16.3098 14.1619H14.9377L14.8013 8H16.4505ZM15.6237 16.821C15.3652 16.821 15.1436 16.7301 14.959 16.5483C14.7772 16.3665 14.6862 16.1449 14.6862 15.8835C14.6862 15.6278 14.7772 15.4091 14.959 15.2273C15.1436 15.0455 15.3652 14.9545 15.6237 14.9545C15.8766 14.9545 16.0953 15.0455 16.28 15.2273C16.4675 15.4091 16.5612 15.6278 16.5612 15.8835C16.5612 16.0568 16.5172 16.2145 16.4291 16.3565C16.3439 16.4986 16.2303 16.6122 16.0882 16.6974C15.949 16.7798 15.7942 16.821 15.6237 16.821Z" fill="#446888"/>
</svg>`;
export const ICON_PRIORITY_MEDIUM = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M10.7642 8L10.6236 14.1619H9.25142L9.11506 8H10.7642ZM9.9375 16.821C9.67898 16.821 9.45739 16.7301 9.27273 16.5483C9.09091 16.3665 9 16.1449 9 15.8835C9 15.6278 9.09091 15.4091 9.27273 15.2273C9.45739 15.0455 9.67898 14.9545 9.9375 14.9545C10.1903 14.9545 10.4091 15.0455 10.5938 15.2273C10.7812 15.4091 10.875 15.6278 10.875 15.8835C10.875 16.0568 10.831 16.2145 10.7429 16.3565C10.6577 16.4986 10.544 16.6122 10.402 16.6974C10.2628 16.7798 10.108 16.821 9.9375 16.821Z" fill="#446888"/>
<path d="M14.1073 8L13.9667 14.1619H12.5945L12.4582 8H14.1073ZM13.2806 16.821C13.0221 16.821 12.8005 16.7301 12.6159 16.5483C12.434 16.3665 12.3431 16.1449 12.3431 15.8835C12.3431 15.6278 12.434 15.4091 12.6159 15.2273C12.8005 15.0455 13.0221 14.9545 13.2806 14.9545C13.5335 14.9545 13.7522 15.0455 13.9369 15.2273C14.1244 15.4091 14.2181 15.6278 14.2181 15.8835C14.2181 16.0568 14.1741 16.2145 14.086 16.3565C14.0008 16.4986 13.8872 16.6122 13.7451 16.6974C13.6059 16.7798 13.4511 16.821 13.2806 16.821Z" fill="#446888"/>
</svg>`;
export const ICON_PRIORITY_LOW = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M12.7642 8L12.6236 14.1619H11.2514L11.1151 8H12.7642ZM11.9375 16.821C11.679 16.821 11.4574 16.7301 11.2727 16.5483C11.0909 16.3665 11 16.1449 11 15.8835C11 15.6278 11.0909 15.4091 11.2727 15.2273C11.4574 15.0455 11.679 14.9545 11.9375 14.9545C12.1903 14.9545 12.4091 15.0455 12.5938 15.2273C12.7812 15.4091 12.875 15.6278 12.875 15.8835C12.875 16.0568 12.831 16.2145 12.7429 16.3565C12.6577 16.4986 12.544 16.6122 12.402 16.6974C12.2628 16.7798 12.108 16.821 11.9375 16.821Z" fill="#446888"/>
</svg>`;
export const ICON_PRIORITY_NONE = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="24" height="24" fill="#F1F5F8"/>
<path d="M13.5686 8L11.1579 16.9562H10L12.4107 8H13.5686Z" fill="#446888"/>
</svg>`;
export const ICON_AI_ASSIST = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m13.314 7.565l-.136.126l-10.48 10.488a2.27 2.27 0 0 0 3.211 3.208L16.388 10.9a2.251 2.251 0 0 0-.001-3.182l-.157-.146a2.25 2.25 0 0 0-2.916-.007Zm-.848 2.961l1.088 1.088l-8.706 8.713a.77.77 0 1 1-1.089-1.088l8.707-8.713Zm4.386 4.48L16.75 15a.75.75 0 0 0-.743.648L16 15.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.694.648.743l.102.007H16v.75c0 .38.282.694.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V18h.75a.75.75 0 0 0 .743-.648L19 17.25a.75.75 0 0 0-.648-.743l-.102-.007h-.75v-.75a.75.75 0 0 0-.648-.743L16.75 15l.102.007Zm-1.553-6.254l.027.027a.751.751 0 0 1 0 1.061l-.711.713l-1.089-1.089l.73-.73a.75.75 0 0 1 1.043.018ZM6.852 5.007L6.75 5a.75.75 0 0 0-.743.648L6 5.75v.75h-.75a.75.75 0 0 0-.743.648L4.5 7.25c0 .38.282.693.648.743L5.25 8H6v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648L7.5 8.75V8h.75a.75.75 0 0 0 .743-.648L9 7.25a.75.75 0 0 0-.648-.743L8.25 6.5H7.5v-.75a.75.75 0 0 0-.648-.743L6.75 5l.102.007Zm12-2L18.75 3a.75.75 0 0 0-.743.648L18 3.75v.75h-.75a.75.75 0 0 0-.743.648l-.007.102c0 .38.282.693.648.743L17.25 6H18v.75c0 .38.282.693.648.743l.102.007a.75.75 0 0 0 .743-.648l.007-.102V6h.75a.75.75 0 0 0 .743-.648L21 5.25a.75.75 0 0 0-.648-.743L20.25 4.5h-.75v-.75a.75.75 0 0 0-.648-.743L18.75 3l.102.007Z" fill="currentColor"/></svg>`;
export const ICON_AI_SUMMARY = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H18a2.5 2.5 0 0 1 2.5 2.5v14.25a.75.75 0 0 1-.75.75H5.5a1 1 0 0 0 1 1h13.25a.75.75 0 0 1 0 1.5H6.5A2.5 2.5 0 0 1 4 19.5v-15ZM5.5 18H19V4.5a1 1 0 0 0-1-1H6.5a1 1 0 0 0-1 1V18Z" fill="currentColor"/></svg>`;
export const ICON_AI_SPELLING = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M4.53 12.97a.75.75 0 0 0-1.06 1.06l4.5 4.5a.75.75 0 0 0 1.06 0l11-11a.75.75 0 0 0-1.06-1.06L8.5 16.94l-3.97-3.97Z" fill="currentColor"/></svg>`;
export const ICON_AI_EXPAND = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.75 19.5h14.5a.75.75 0 0 0 .102-1.493L21.25 18H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm0-15h14.5a.75.75 0 0 0 .102-1.493L21.25 3H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm7 3.5a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM13 13.75a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1-.75-.75Zm-2-2.25a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0Zm-4-2a.5.5 0 0 0-1 0V11H4.5a.5.5 0 0 0 0 1H6v1.5a.5.5 0 0 0 1 0V12h1.5a.5.5 0 0 0 0-1H7V9.5Z" fill="currentColor"/></svg>`;
export const ICON_AI_SHORTEN = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M6.75 4.5h14.5a.75.75 0 0 0 .102-1.493L21.25 3H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm0 15h14.5a.75.75 0 0 0 .102-1.493L21.25 18H6.75a.75.75 0 0 0-.102 1.493l.102.007Zm7-11.5a.75.75 0 0 0 0 1.5h7.5a.75.75 0 0 0 0-1.5h-7.5ZM13 13.75a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5a.75.75 0 0 1-.75-.75Zm-2-2.25a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0Zm-2 0a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 0 0 1h4a.5.5 0 0 0 .5-.5Z" fill="currentColor"/></svg>`;
export const ICON_AI_GRAMMAR = `<svg role="img" class="ninja-icon ninja-icon--fluent" width="18" height="18" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M3 17h7.522l-2 2H3a1 1 0 0 1-.117-1.993L3 17Zm0-2h7.848a1.75 1.75 0 0 1-.775-2H3l-.117.007A1 1 0 0 0 3 15Zm0-8h18l.117-.007A1 1 0 0 0 21 5H3l-.117.007A1 1 0 0 0 3 7Zm9.72 9.216a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 1 1-1.06-1.06l4.5-4.5ZM3 9h10a1 1 0 0 1 .117 1.993L13 11H3a1 1 0 0 1-.117-1.993L3 9Zm13.5-1a.75.75 0 0 1 .744.658l.14 1.13a3.25 3.25 0 0 0 2.828 2.829l1.13.139a.75.75 0 0 1 0 1.488l-1.13.14a3.25 3.25 0 0 0-2.829 2.828l-.139 1.13a.75.75 0 0 1-1.488 0l-.14-1.13a3.25 3.25 0 0 0-2.828-2.829l-1.13-.139a.75.75 0 0 1 0-1.488l1.13-.14a3.25 3.25 0 0 0 2.829-2.828l.139-1.13A.75.75 0 0 1 16.5 8Z" fill="currentColor"/></svg>`;
export const ICON_APPEARANCE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18"viewBox="0 0 24 24"><path fill="currentColor" d="M3.839 5.858c2.94-3.916 9.03-5.055 13.364-2.36c4.28 2.66 5.854 7.777 4.1 12.577c-1.655 4.533-6.016 6.328-9.159 4.048c-1.177-.854-1.634-1.925-1.854-3.664l-.106-.987l-.045-.398c-.123-.934-.311-1.352-.705-1.572c-.535-.298-.892-.305-1.595-.033l-.351.146l-.179.078c-1.014.44-1.688.595-2.541.416l-.2-.047l-.164-.047c-2.789-.864-3.202-4.647-.565-8.157Zm.984 6.716l.123.037l.134.03c.439.087.814.015 1.437-.242l.602-.257c1.202-.493 1.985-.54 3.046.05c.917.512 1.275 1.298 1.457 2.66l.053.459l.055.532l.047.422c.172 1.361.485 2.09 1.248 2.644c2.275 1.65 5.534.309 6.87-3.349c1.516-4.152.174-8.514-3.484-10.789c-3.675-2.284-8.899-1.306-11.373 1.987c-2.075 2.763-1.82 5.28-.215 5.816Zm11.225-1.994a1.25 1.25 0 1 1 2.414-.647a1.25 1.25 0 0 1-2.414.647Zm.494 3.488a1.25 1.25 0 1 1 2.415-.647a1.25 1.25 0 0 1-2.415.647ZM14.07 7.577a1.25 1.25 0 1 1 2.415-.647a1.25 1.25 0 0 1-2.415.647Zm-.028 8.998a1.25 1.25 0 1 1 2.414-.647a1.25 1.25 0 0 1-2.414.647Zm-3.497-9.97a1.25 1.25 0 1 1 2.415-.646a1.25 1.25 0 0 1-2.415.646Z"/></svg>`;
export const ICON_LIGHT_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 2Zm5 10a5 5 0 1 1-10 0a5 5 0 0 1 10 0Zm4.25.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM12 19a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 12 19Zm-7.75-6.25a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5Zm-.03-8.53a.75.75 0 0 1 1.06 0l1.5 1.5a.75.75 0 0 1-1.06 1.06l-1.5-1.5a.75.75 0 0 1 0-1.06Zm1.06 15.56a.75.75 0 1 1-1.06-1.06l1.5-1.5a.75.75 0 1 1 1.06 1.06l-1.5 1.5Zm14.5-15.56a.75.75 0 0 0-1.06 0l-1.5 1.5a.75.75 0 0 0 1.06 1.06l1.5-1.5a.75.75 0 0 0 0-1.06Zm-1.06 15.56a.75.75 0 1 0 1.06-1.06l-1.5-1.5a.75.75 0 1 0-1.06 1.06l1.5 1.5Z"/></svg>`;
export const ICON_DARK_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M20.026 17.001c-2.762 4.784-8.879 6.423-13.663 3.661A9.965 9.965 0 0 1 3.13 17.68a.75.75 0 0 1 .365-1.132c3.767-1.348 5.785-2.91 6.956-5.146c1.232-2.353 1.551-4.93.689-8.463a.75.75 0 0 1 .769-.927a9.961 9.961 0 0 1 4.457 1.327c4.784 2.762 6.423 8.879 3.66 13.662Zm-8.248-4.903c-1.25 2.389-3.31 4.1-6.817 5.499a8.49 8.49 0 0 0 2.152 1.766a8.502 8.502 0 0 0 8.502-14.725a8.484 8.484 0 0 0-2.792-1.015c.647 3.384.23 6.043-1.045 8.475Z"/></svg>`;
export const ICON_SYSTEM_MODE = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M4.25 3A2.25 2.25 0 0 0 2 5.25v10.5A2.25 2.25 0 0 0 4.25 18H9.5v1.25c0 .69-.56 1.25-1.25 1.25h-.5a.75.75 0 0 0 0 1.5h8.5a.75.75 0 0 0 0-1.5h-.5c-.69 0-1.25-.56-1.25-1.25V18h5.25A2.25 2.25 0 0 0 22 15.75V5.25A2.25 2.25 0 0 0 19.75 3H4.25ZM13 18v1.25c0 .45.108.875.3 1.25h-2.6c.192-.375.3-.8.3-1.25V18h2ZM3.5 5.25a.75.75 0 0 1 .75-.75h15.5a.75.75 0 0 1 .75.75V13h-17V5.25Zm0 9.25h17v1.25a.75.75 0 0 1-.75.75H4.25a.75.75 0 0 1-.75-.75V14.5Z"/></svg>`;
export const ICON_SNOOZE_NOTIFICATION = `<svg role="img" class="ninja-icon ninja-icon--fluent" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24"><path fill="currentColor" d="M12 3.5c-3.104 0-6 2.432-6 6.25v4.153L4.682 17h14.67l-1.354-3.093V11.75a.75.75 0 0 1 1.5 0v1.843l1.381 3.156a1.25 1.25 0 0 1-1.145 1.751H15a3.002 3.002 0 0 1-6.003 0H4.305a1.25 1.25 0 0 1-1.15-1.739l1.344-3.164V9.75C4.5 5.068 8.103 2 12 2c.86 0 1.705.15 2.5.432a.75.75 0 0 1-.502 1.413A5.964 5.964 0 0 0 12 3.5ZM12 20c.828 0 1.5-.671 1.501-1.5h-3.003c0 .829.673 1.5 1.502 1.5Zm3.25-13h-2.5l-.101.007A.75.75 0 0 0 12.75 8.5h1.043l-1.653 2.314l-.055.09A.75.75 0 0 0 12.75 12h2.5l.102-.007a.75.75 0 0 0-.102-1.493h-1.042l1.653-2.314l.055-.09A.75.75 0 0 0 15.25 7Zm6-5h-3.5l-.101.007A.75.75 0 0 0 17.75 3.5h2.134l-2.766 4.347l-.05.09A.75.75 0 0 0 17.75 9h3.5l.102-.007A.75.75 0 0 0 21.25 7.5h-2.133l2.766-4.347l.05-.09A.75.75 0 0 0 21.25 2Z"/></svg>`;

View File

@@ -0,0 +1,113 @@
/* eslint no-param-reassign: 0 */
import getUuid from 'widget/helpers/uuid';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
export default () => {
if (!Array.prototype.last) {
Object.assign(Array.prototype, {
last() {
return this[this.length - 1];
},
});
}
};
export const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object;
export const isJSONValid = value => {
try {
JSON.parse(value);
} catch (e) {
return false;
}
return true;
};
export const getTypingUsersText = (users = []) => {
const count = users.length;
const [firstUser, secondUser] = users;
if (count === 1) {
return ['TYPING.ONE', { user: firstUser.name }];
}
if (count === 2) {
return [
'TYPING.TWO',
{ user: firstUser.name, secondUser: secondUser.name },
];
}
return ['TYPING.MULTIPLE', { user: firstUser.name, count: count - 1 }];
};
export const createPendingMessage = data => {
const timestamp = Math.floor(new Date().getTime() / 1000);
const tempMessageId = getUuid();
const { message, file } = data;
const tempAttachments = [{ id: tempMessageId }];
const pendingMessage = {
...data,
content: message || null,
id: tempMessageId,
echo_id: tempMessageId,
status: MESSAGE_STATUS.PROGRESS,
created_at: timestamp,
message_type: MESSAGE_TYPE.OUTGOING,
conversation_id: data.conversationId,
attachments: file ? tempAttachments : null,
};
return pendingMessage;
};
export const convertToAttributeSlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '_');
};
export const convertToCategorySlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
export const convertToPortalSlug = text => {
return text
.toLowerCase()
.replace(/[^\w ]+/g, '')
.replace(/ +/g, '-');
};
/**
* Strip curly braces, commas and leading/trailing whitespace from a search key.
* Eg. "{{contact.name}}," => "contact.name"
* @param {string} searchKey
* @returns {string}
*/
export const sanitizeVariableSearchKey = (searchKey = '') => {
return searchKey
.replace(/[{}]/g, '') // remove all curly braces
.replace(/,/g, '') // remove commas
.trim();
};
/**
* Convert underscore-separated string to title case.
* Eg. "round_robin" => "Round Robin"
* @param {string} str
* @returns {string}
*/
export const formatToTitleCase = str => {
return (
str
?.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase())
.trim() || ''
);
};

View File

@@ -0,0 +1,94 @@
/**
* Determines the last non-activity message between store and API messages.
* @param {Object} messageInStore - The last non-activity message from the store.
* @param {Object} messageFromAPI - The last non-activity message from the API.
* @returns {Object} The latest non-activity message.
*/
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
// If both API value and store value for last non activity message
// are available, then return the latest one.
if (messageInStore && messageFromAPI) {
return messageInStore.created_at >= messageFromAPI.created_at
? messageInStore
: messageFromAPI;
}
// Otherwise, return whichever is available
return messageInStore || messageFromAPI;
};
/**
* Filters out duplicate source messages from an array of messages.
* @param {Array} messages - The array of messages to filter.
* @returns {Array} An array of messages without duplicates.
*/
export const filterDuplicateSourceMessages = (messages = []) => {
const messagesWithoutDuplicates = [];
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
// We should return the message with smaller id when there is a duplicate
messages.forEach(m1 => {
if (m1.source_id) {
const index = messagesWithoutDuplicates.findIndex(
m2 => m1.source_id === m2.source_id
);
if (index < 0) {
messagesWithoutDuplicates.push(m1);
}
} else {
messagesWithoutDuplicates.push(m1);
}
});
return messagesWithoutDuplicates;
};
/**
* Retrieves the last message from a conversation, prioritizing non-activity messages.
* @param {Object} m - The conversation object containing messages.
* @returns {Object} The last message of the conversation.
*/
export const getLastMessage = m => {
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
const nonActivityMessages = m.messages.filter(
message => message.message_type !== 2
);
const lastNonActivityMessageInStore =
nonActivityMessages[nonActivityMessages.length - 1];
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
// If API value and store value for last non activity message
// is empty, then return the last activity message
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
return lastMessageIncludingActivity;
}
return getLastNonActivityMessage(
lastNonActivityMessageInStore,
lastNonActivityMessageFromAPI
);
};
/**
* Filters messages that have been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of read messages.
*/
export const getReadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 <= agentLastSeenAt * 1000
);
};
/**
* Filters messages that have not been read by the agent.
* @param {Array} messages - The array of messages to filter.
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
* @returns {Array} An array of unread messages.
*/
export const getUnreadMessages = (messages, agentLastSeenAt) => {
return messages.filter(
message => message.created_at * 1000 > agentLastSeenAt * 1000
);
};

View File

@@ -0,0 +1,130 @@
export const getInputType = (key, operator, filterTypes) => {
if (key === 'created_at' || key === 'last_activity_at')
if (operator === 'days_before') return 'plain_text';
const type = filterTypes.find(filter => filter.attributeKey === key);
return type?.inputType;
};
export const generateCustomAttributesInputType = type => {
const filterInputTypes = {
text: 'string',
number: 'string',
date: 'string',
checkbox: 'multi_select',
list: 'multi_select',
link: 'string',
};
return filterInputTypes[type];
};
export const getAttributeInputType = (key, allCustomAttributes) => {
const customAttribute = allCustomAttributes.find(
attr => attr.attribute_key === key
);
const { attribute_display_type } = customAttribute;
const filterInputTypes = generateCustomAttributesInputType(
attribute_display_type
);
return filterInputTypes;
};
export const getValuesName = (values, list, idKey, nameKey) => {
const item = list?.find(v => v[idKey] === values[0]);
return {
id: values[0],
name: item ? item[nameKey] : values[0],
};
};
export const getValuesForStatus = values => {
return values.map(value => ({ id: value, name: value }));
};
const getValuesForLabels = (values, labels) => {
const selectedLabels = labels.filter(label => values.includes(label.title));
return selectedLabels.map(({ title }) => ({
id: title,
name: title,
}));
};
const getValuesForLanguages = (values, languages) => {
const selectedLanguages = languages.filter(language =>
values.includes(language.id)
);
return selectedLanguages.map(({ id, name }) => ({
id: id.toLowerCase(),
name: name,
}));
};
const getValuesForCountries = (values, countries) => {
const selectedCountries = countries.filter(country =>
values.includes(country.id)
);
return selectedCountries.map(({ id, name }) => ({
id: id,
name: name,
}));
};
const getValuesForPriority = (values, priority) => {
return priority.filter(option => values.includes(option.id));
};
export const getValuesForFilter = (filter, params) => {
const { attribute_key, values } = filter;
const {
languages,
countries,
agents,
inboxes,
teams,
campaigns,
labels,
priority,
} = params;
switch (attribute_key) {
case 'status':
return getValuesForStatus(values);
case 'assignee_id':
return getValuesName(values, agents, 'id', 'name');
case 'inbox_id':
return getValuesName(values, inboxes, 'id', 'name');
case 'team_id':
return getValuesName(values, teams, 'id', 'name');
case 'campaign_id':
return getValuesName(values, campaigns, 'id', 'title');
case 'labels':
return getValuesForLabels(values, labels);
case 'priority':
return getValuesForPriority(values, priority);
case 'browser_language':
return getValuesForLanguages(values, languages);
case 'country_code':
return getValuesForCountries(values, countries);
default:
return { id: values[0], name: values[0] };
}
};
export const generateValuesForEditCustomViews = (filter, params) => {
const { attribute_key, filter_operator, values } = filter;
const { filterTypes, allCustomAttributes } = params;
const inputType = getInputType(attribute_key, filter_operator, filterTypes);
if (inputType === undefined) {
const filterInputTypes = getAttributeInputType(
attribute_key,
allCustomAttributes
);
return filterInputTypes === 'string'
? values[0].toString()
: { id: values[0], name: values[0] };
}
return inputType === 'multi_select' || inputType === 'search_select'
? getValuesForFilter(filter, params)
: values[0].toString();
};

View File

@@ -0,0 +1,22 @@
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
export const downloadCsvFile = (fileName, content) => {
const contentType = 'data:text/csv;charset=utf-8;';
const blob = new Blob([content], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.setAttribute('download', fileName);
link.setAttribute('href', url);
link.click();
return link;
};
export const generateFileName = ({ type, to, businessHours = false }) => {
let name = `${type}-report-${format(fromUnixTime(to), 'dd-MM-yyyy')}`;
if (businessHours) {
name = `${name}-business-hours`;
}
return `${name}.csv`;
};

View File

@@ -0,0 +1,606 @@
import {
messageSchema,
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/vue';
import { FORMATTING, MARKDOWN_PATTERNS } from 'dashboard/constants/editor';
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
import camelcaseKeys from 'camelcase-keys';
/**
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
* Links will be converted to text, and not removed.
*
* @param {string} markdown - markdown text to be extracted
* @returns {string} - The extracted text.
*/
export function extractTextFromMarkdown(markdown) {
if (!markdown) return '';
return markdown
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
.replace(/`.*?`/g, '') // Remove inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n') // Trim each line & remove any lines only having spaces
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
.trim(); // Trim any extra space
}
/**
* Strip unsupported markdown formatting based on channel capabilities.
* Uses MARKDOWN_PATTERNS from editor constants.
*
* @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 stripUnsupportedMarkdown(
markdown,
channelType,
cleanWhitespace = true
) {
if (!markdown) return '';
const { marks = [], nodes = [] } = FORMATTING[channelType] || {};
const supported = [...marks, ...nodes];
// Apply patterns from MARKDOWN_PATTERNS for unsupported types
const result = MARKDOWN_PATTERNS.reduce((text, { type, patterns }) => {
if (supported.includes(type)) return text;
return patterns.reduce(
(t, { pattern, replacement }) => t.replace(pattern, replacement),
text
);
}, markdown);
if (!cleanWhitespace) return result;
// Clean whitespace for signatures
return result
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.join('\n')
.replace(/\n{2,}/g, '\n')
.trim();
}
/**
* The delimiter used to separate the signature from the rest of the body.
* @type {string}
*/
export const SIGNATURE_DELIMITER = '--';
/**
* Parse and Serialize the markdown text to remove any extra spaces or new lines
*/
export function cleanSignature(signature) {
try {
// remove any horizontal rule tokens
signature = signature
.replace(/^( *\* *){3,} *$/gm, '')
.replace(/^( *- *){3,} *$/gm, '')
.replace(/^( *_ *){3,} *$/gm, '');
const nodes = new MessageMarkdownTransformer(messageSchema).parse(
signature
);
return MessageMarkdownSerializer.serialize(nodes);
} catch (e) {
// eslint-disable-next-line no-console
console.warn(e);
Sentry.captureException(e);
// The parser can break on some cases
// for example, Token type `hr` not supported by Markdown parser
return signature;
}
}
/**
* Adds the signature delimiter to the beginning of the signature.
*
* @param {string} signature - The signature to add the delimiter to.
* @returns {string} - The signature with the delimiter added.
*/
function appendDelimiter(signature) {
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
}
/**
* Check if there's an unedited signature at the end of the body
* If there is, return the index of the signature, If there isn't, return -1
*
* @param {string} body - The body to search for the signature.
* @param {string} signature - The signature to search for.
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
*/
export function findSignatureInBody(body, signature) {
const trimmedBody = body.trimEnd();
const cleanedSignature = cleanSignature(signature);
// check if body ends with signature
if (trimmedBody.endsWith(cleanedSignature)) {
return body.lastIndexOf(cleanedSignature);
}
return -1;
}
/**
* Gets the effective channel type for formatting purposes.
* For Twilio channels, returns WhatsApp or Twilio based on medium.
*
* @param {string} channelType - The channel type
* @param {string} medium - Optional. The medium for Twilio channels (sms/whatsapp)
* @returns {string} - The effective channel type for formatting
*/
export function getEffectiveChannelType(channelType, medium) {
if (channelType === INBOX_TYPES.TWILIO) {
return medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP
? INBOX_TYPES.WHATSAPP
: INBOX_TYPES.TWILIO;
}
return channelType;
}
/**
* Appends the signature to the body, separated by the signature delimiter.
* Automatically strips unsupported formatting based on channel capabilities.
*
* @param {string} body - The body to append the signature to.
* @param {string} signature - The signature to append.
* @param {string} channelType - Optional. The effective channel type to determine supported formatting.
* For Twilio channels, pass the result of getEffectiveChannelType().
* @returns {string} - The body with the signature appended.
*/
export function appendSignature(body, signature, channelType) {
// Strip only unsupported formatting based on channel capabilities
const preparedSignature = channelType
? stripUnsupportedMarkdown(signature, channelType)
: signature;
const cleanedSignature = cleanSignature(preparedSignature);
// if signature is already present, return body
if (findSignatureInBody(body, cleanedSignature) > -1) {
return body;
}
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
}
/**
* Removes the signature from the body, along with the signature delimiter.
* Tries multiple signature variants: original, channel-stripped, and fully stripped.
*
* @param {string} body - The body to remove the signature from.
* @param {string} signature - The signature to remove.
* @param {string} channelType - Optional. The effective channel type for channel-specific stripping.
* @returns {string} - The body with the signature removed.
*/
export function removeSignature(body, signature, channelType) {
// Build unique list of signature variants to try
const channelStripped = channelType
? cleanSignature(stripUnsupportedMarkdown(signature, channelType))
: null;
const signaturesToTry = [
cleanSignature(signature),
channelStripped,
cleanSignature(extractTextFromMarkdown(signature)),
].filter((sig, i, arr) => sig && arr.indexOf(sig) === i); // Remove nulls and duplicates
// Find the first matching signature
const signatureIndex = signaturesToTry.reduce(
(index, sig) => (index === -1 ? findSignatureInBody(body, sig) : index),
-1
);
// no need to trim the ends here, because it will simply be removed in the next method
let newBody = body;
// if signature is present, remove it and trim it
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
newBody = newBody.substring(0, signatureIndex).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
}
return newBody;
}
/**
* Replaces the old signature with the new signature.
* If the old signature is not present, it will append the new signature.
*
* @param {string} body - The body to replace the signature in.
* @param {string} oldSignature - The signature to replace.
* @param {string} newSignature - The signature to replace the old signature with.
* @returns {string} - The body with the old signature replaced with the new signature.
*
*/
export function replaceSignature(body, oldSignature, newSignature) {
const withoutSignature = removeSignature(body, oldSignature);
return appendSignature(withoutSignature, newSignature);
}
/**
* Scrolls the editor view into current cursor position
*
* @param {EditorView} view - The Prosemirror EditorView
*
*/
export const scrollCursorIntoView = view => {
// Get the current selection's head position (where the cursor is).
const pos = view.state.selection.head;
// Get the corresponding DOM node for that position.
const domAtPos = view.domAtPos(pos);
const node = domAtPos.node;
// Scroll the node into view.
if (node && node.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
};
/**
* Returns a transaction that inserts a node into editor at the given position
* Has an optional param 'content' to check if the
*
* @param {Node} node - The prosemirror node that needs to be inserted into the editor
* @param {number} from - Position in the editor where the node needs to be inserted
* @param {number} to - Position in the editor where the node needs to be replaced
*
*/
export function insertAtCursor(editorView, node, from, to) {
if (!editorView) {
return undefined;
}
// This is a workaround to prevent inserting content into new line rather than on the exiting line
// If the node is of type 'doc' and has only one child which is a paragraph,
// then extract its inline content to be inserted as inline.
const isWrappedInParagraph =
node.type.name === 'doc' &&
node.childCount === 1 &&
node.firstChild.type.name === 'paragraph';
if (isWrappedInParagraph) {
node = node.firstChild.content;
}
let tr;
if (to) {
tr = editorView.state.tr.replaceWith(from, to, node).insertText(` `);
} else {
tr = editorView.state.tr.insert(from, node);
}
const state = editorView.state.apply(tr);
editorView.updateState(state);
editorView.focus();
return state;
}
/**
* Determines the appropriate node and position to insert an image in the editor.
*
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
*
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
* in a new paragraph and then inserted.
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
*
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
* @property {number} pos - The position where the new node should be inserted in the editor.
*/
export const findNodeToInsertImage = (editorState, fileUrl) => {
const { selection, schema } = editorState;
const { nodes } = schema;
const currentNode = selection.$from.node();
const {
type: { name: typeName },
content: { size, content },
} = currentNode;
const imageNode = nodes.image.create({ src: fileUrl });
if (!imageNode) return null;
const isInParagraph = typeName === 'paragraph';
const needsNewLine =
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
return {
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
pos: selection.from + needsNewLine,
};
};
/**
* Set URL with query and size.
*
* @param {Object} selectedImageNode - The current selected node.
* @param {Object} size - The size to set.
* @param {Object} editorView - The editor view.
*/
export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
if (selectedImageNode) {
// Create and apply the transaction
const tr = editorView.state.tr.setNodeMarkup(
editorView.state.selection.from,
null,
{
src: selectedImageNode.src,
height: size.height,
}
);
if (tr.docChanged) {
editorView.dispatch(tr);
}
}
}
/**
* Strips unsupported markdown formatting from content based on the editor schema.
* This ensures canned responses with rich formatting can be inserted into channels
* that don't support certain formatting (e.g., API channels don't support bold).
*
* @param {string} content - The markdown content to sanitize
* @param {Object} schema - The ProseMirror schema with supported marks and nodes
* @returns {string} - Content with unsupported formatting stripped
*/
export function stripUnsupportedFormatting(content, schema) {
if (!content || typeof content !== 'string') return content;
if (!schema) return content;
let sanitizedContent = content;
// Get supported marks and nodes from the schema
// Note: ProseMirror uses snake_case internally (code_block, bullet_list, etc.)
// but our FORMATTING constant uses camelCase (codeBlock, bulletList, etc.)
// We use camelcase-keys to normalize node names for comparison
const supportedMarks = Object.keys(schema.marks || {});
const nodeKeys = Object.keys(schema.nodes || {});
const nodeKeysObj = Object.fromEntries(nodeKeys.map(k => [k, true]));
const supportedNodes = Object.keys(camelcaseKeys(nodeKeysObj));
// Process each formatting type in order (codeBlock before code is important!)
MARKDOWN_PATTERNS.forEach(({ type, patterns }) => {
// Check if this format type is supported by the schema
const isMarkSupported = supportedMarks.includes(type);
const isNodeSupported = supportedNodes.includes(type);
// If not supported, strip the formatting
if (!isMarkSupported && !isNodeSupported) {
patterns.forEach(({ pattern, replacement }) => {
sanitizedContent = sanitizedContent.replace(pattern, replacement);
});
}
});
return sanitizedContent;
}
/**
* Content Node Creation Helper Functions for
* - mention
* - canned response
* - variable
* - emoji
*/
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
*/
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention': {
const mentionType = content.type || 'user';
const displayName = content.displayName || content.name;
const mentionNode = state.schema.nodes.mention.create({
userId: content.id,
userFullName: displayName,
mentionType,
});
return mentionNode;
}
case 'cannedResponse': {
// Strip unsupported formatting before parsing to ensure content can be inserted
// into channels that don't support certain markdown features (e.g., API channels)
const sanitizedContent = stripUnsupportedFormatting(
content,
state.schema
);
return new MessageMarkdownTransformer(state.schema).parse(
sanitizedContent
);
}
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
case 'tool': {
return state.schema.nodes.tools.create({
id: content.id,
name: content.title,
});
}
default:
return null;
}
};
/**
* Object mapping types to their respective node creation functions.
*/
const nodeCreators = {
mention: (editorView, content, from, to) => ({
node: createNode(editorView, 'mention', content),
from,
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
from: node.textContent === updatedMessage ? from : from - 1,
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
emoji: (editorView, content, from, to) => ({
node: createNode(editorView, 'emoji', content),
from,
to,
}),
tool: (editorView, content, from, to) => ({
node: createNode(editorView, 'tool', content),
from,
to,
}),
};
/**
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
* @param {Object} editorView - The editor view instance.
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {string|Object} content - The content to be transformed into a node.
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
*/
export const getContentNode = (
editorView,
type,
content,
{ from, to },
variables
) => {
const creator = nodeCreators[type];
return creator
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
/**
* Get the formatting configuration for a specific channel type.
* Returns the appropriate marks, nodes, and menu items for the editor.
* TODO: We're hiding captain, enable it back when we add selection improvements
*
* @param {string} channelType - The channel type (e.g., 'Channel::FacebookPage', 'Channel::WebWidget')
* @returns {Object} The formatting configuration with marks, nodes, and menu properties
*/
export function getFormattingForEditor(channelType, showCaptain = false) {
const formatting = FORMATTING[channelType] || FORMATTING['Context::Default'];
return {
...formatting,
menu: showCaptain
? formatting.menu
: formatting.menu.filter(item => item !== 'copilot'),
};
}
/**
* Menu Positioning Helpers
* Handles floating menu bar positioning for text selection in the editor.
*/
const MENU_CONFIG = { H: 46, W: 300, GAP: 10 };
/**
* Calculate selection coordinates with bias to handle line-wraps correctly.
* @param {EditorView} editorView - ProseMirror editor view
* @param {Selection} selection - Current text selection
* @param {DOMRect} rect - Container bounding rect
* @returns {{start: Object, end: Object, selTop: number, onTop: boolean}}
*/
export function getSelectionCoords(editorView, selection, rect) {
const start = editorView.coordsAtPos(selection.from, 1);
const end = editorView.coordsAtPos(selection.to, -1);
const selTop = Math.min(start.top, end.top);
const spaceAbove = selTop - rect.top;
const onTop =
spaceAbove > MENU_CONFIG.H + MENU_CONFIG.GAP || end.bottom > rect.bottom;
return { start, end, selTop, onTop };
}
/**
* Calculate anchor position based on selection visibility and RTL direction.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {number} Anchor x-position for menu
*/
export function getMenuAnchor(coords, rect, isRtl) {
const { start, end, onTop } = coords;
if (!onTop) return end.left;
// If start of selection is visible, align to text. Else stick to container edge.
if (start.top >= rect.top) return isRtl ? start.right : start.left;
return isRtl ? rect.right - MENU_CONFIG.GAP : rect.left + MENU_CONFIG.GAP;
}
/**
* Calculate final menu position (left, top) within container bounds.
* @param {Object} coords - Selection coordinates from getSelectionCoords
* @param {DOMRect} rect - Container bounding rect
* @param {boolean} isRtl - Whether text direction is RTL
* @returns {{left: number, top: number, width: number}}
*/
export function calculateMenuPosition(coords, rect, isRtl) {
const { start, end, selTop, onTop } = coords;
const anchor = getMenuAnchor(coords, rect, isRtl);
// Calculate Left: shift by width if RTL, then make relative to container
const rawLeft = (isRtl ? anchor - MENU_CONFIG.W : anchor) - rect.left;
// Ensure menu stays within container bounds
const left = Math.min(Math.max(0, rawLeft), rect.width - MENU_CONFIG.W);
// Calculate Top: align to selection or bottom of selection
const top = onTop
? Math.max(-26, selTop - rect.top - MENU_CONFIG.H - MENU_CONFIG.GAP)
: Math.max(start.bottom, end.bottom) - rect.top + MENU_CONFIG.GAP;
return { left, top, width: MENU_CONFIG.W };
}
/* End Menu Positioning Helpers */

View File

@@ -0,0 +1,158 @@
import DOMPurify from 'dompurify';
// Quote detection strategies
const QUOTE_INDICATORS = [
'.gmail_quote_container',
'.gmail_quote',
'.OutlookQuote',
'.email-quote',
'.quoted-text',
'.quote',
'[class*="quote"]',
'[class*="Quote"]',
];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
/-----Original Message-----/i,
/Sent: /i,
/From: /i,
];
export class EmailQuoteExtractor {
/**
* Remove quotes from email HTML and return cleaned HTML
* @param {string} htmlContent - Full HTML content of the email
* @returns {string} HTML content with quotes removed
*/
static extractQuotes(htmlContent) {
// Create a temporary DOM element to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Remove elements matching class selectors
QUOTE_INDICATORS.forEach(selector => {
tempDiv.querySelectorAll(selector).forEach(el => {
el.remove();
});
});
this.removeTrailingBlockquote(tempDiv);
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
el.remove();
});
return tempDiv.innerHTML;
}
/**
* Check if HTML content contains any quotes
* @param {string} htmlContent - Full HTML content of the email
* @returns {boolean} True if quotes are detected, false otherwise
*/
static hasQuotes(htmlContent) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(htmlContent);
// Check for class-based quotes
// eslint-disable-next-line no-restricted-syntax
for (const selector of QUOTE_INDICATORS) {
if (tempDiv.querySelector(selector)) {
return true;
}
}
if (this.findTrailingBlockquote(tempDiv)) {
return true;
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
}
/**
* Find text nodes that match quote patterns
* @param {Element} rootElement - Root element to search
* @returns {Element[]} Array of parent block elements containing quote-like text
*/
static findTextNodeQuotes(rootElement) {
const quoteBlocks = [];
const treeWalker = document.createTreeWalker(
rootElement,
NodeFilter.SHOW_TEXT,
null,
false
);
for (
let currentNode = treeWalker.nextNode();
currentNode !== null;
currentNode = treeWalker.nextNode()
) {
const isQuoteLike = QUOTE_PATTERNS.some(pattern =>
pattern.test(currentNode.textContent)
);
if (isQuoteLike) {
const parentBlock = this.findParentBlock(currentNode);
if (parentBlock && !quoteBlocks.includes(parentBlock)) {
quoteBlocks.push(parentBlock);
}
}
}
return quoteBlocks;
}
/**
* Find the closest block-level parent element by recursively traversing up the DOM tree.
* This method searches for common block-level elements like DIV, P, BLOCKQUOTE, and SECTION
* that contain the text node. It's used to identify and remove entire block-level elements
* that contain quote-like text, rather than just removing the text node itself. This ensures
* proper structural removal of quoted content while maintaining HTML integrity.
* @param {Node} node - Starting node to find parent
* @returns {Element|null} Block-level parent element
*/
static findParentBlock(node) {
const blockElements = ['DIV', 'P', 'BLOCKQUOTE', 'SECTION'];
let current = node.parentElement;
while (current) {
if (blockElements.includes(current.tagName)) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
}
}

View File

@@ -0,0 +1,28 @@
const FEATURE_HELP_URLS = {
agent_bots: 'https://chwt.app/hc/agent-bots',
agents: 'https://chwt.app/hc/agents',
audit_logs: 'https://chwt.app/hc/audit-logs',
campaigns: 'https://chwt.app/hc/campaigns',
canned_responses: 'https://chwt.app/hc/canned',
channel_email: 'https://chwt.app/hc/email',
channel_facebook: 'https://chwt.app/hc/fb',
custom_attributes: 'https://chwt.app/hc/custom-attributes',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
help_center: 'https://chwt.app/hc/help-center',
inboxes: 'https://chwt.app/hc/inboxes',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
message_reply_to: 'https://chwt.app/hc/reply-to',
reports: 'https://chwt.app/hc/reports',
sla: 'https://chwt.app/hc/sla',
team_management: 'https://chwt.app/hc/teams',
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
export function getHelpUrlForFeature(featureName) {
return FEATURE_HELP_URLS[featureName];
}

View File

@@ -0,0 +1,39 @@
const setArrayValues = item => {
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
};
const generateValues = item => {
if (item.attribute_key === 'content') {
const values = item.values || '';
return values.split(',');
}
if (Array.isArray(item.values)) {
return setArrayValues(item);
}
if (typeof item.values === 'object') {
return [item.values.id];
}
if (!item.values) {
return [];
}
return [item.values];
};
const generatePayload = data => {
// Make a copy of data to avoid vue data reactivity issues
const filters = JSON.parse(JSON.stringify(data));
let payload = filters.map(item => {
// If item key is content, we will split it using comma and return as array
// FIX ME: Make this generic option instead of using the key directly here
item.values = generateValues(item);
return item;
});
// For every query added, the query_operator is set default to and so the
// last query will have an extra query_operator, this would break the api.
// Setting this to null for all query payload
payload[payload.length - 1].query_operator = undefined;
return { payload };
};
export default generatePayload;

View File

@@ -0,0 +1,19 @@
const FLAG_OFFSET = 127397;
/**
* Gets emoji flag for given locale.
*
* @param {string} countryCode locale code
* @return {string} emoji flag
*
* @example
* getCountryFlag('cz') // '🇨🇿'
*/
export const getCountryFlag = countryCode => {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => FLAG_OFFSET + char.charCodeAt());
return String.fromCodePoint(...codePoints);
};

View File

@@ -0,0 +1,173 @@
export const INBOX_TYPES = {
WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
INSTAGRAM: 'Channel::Instagram',
TIKTOK: 'Channel::Tiktok',
VOICE: 'Channel::Voice',
};
export const TWILIO_CHANNEL_MEDIUM = {
WHATSAPP: 'whatsapp',
SMS: 'sms',
};
const INBOX_ICON_MAP_FILL = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill',
[INBOX_TYPES.API]: 'i-ri-cloudy-fill',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
[INBOX_TYPES.TIKTOK]: 'i-ri-tiktok-fill',
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
};
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
const INBOX_ICON_MAP_LINE = {
[INBOX_TYPES.WEB]: 'i-woot-website',
[INBOX_TYPES.FB]: 'i-woot-messenger',
[INBOX_TYPES.TWITTER]: 'i-woot-x',
[INBOX_TYPES.WHATSAPP]: 'i-woot-whatsapp',
[INBOX_TYPES.API]: 'i-woot-api',
[INBOX_TYPES.EMAIL]: 'i-woot-mail',
[INBOX_TYPES.TELEGRAM]: 'i-woot-telegram',
[INBOX_TYPES.LINE]: 'i-woot-line',
[INBOX_TYPES.INSTAGRAM]: 'i-woot-instagram',
[INBOX_TYPES.VOICE]: 'i-woot-voice',
[INBOX_TYPES.TIKTOK]: 'i-woot-tiktok',
};
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
case INBOX_TYPES.WEB:
return inbox.website_url || '';
case INBOX_TYPES.TWILIO:
case INBOX_TYPES.WHATSAPP:
case INBOX_TYPES.VOICE:
return phoneNumber || '';
case INBOX_TYPES.EMAIL:
return inbox.email || '';
default:
return '';
}
};
export const getReadableInboxByType = (type, phoneNumber) => {
switch (type) {
case INBOX_TYPES.WEB:
return 'livechat';
case INBOX_TYPES.FB:
return 'facebook';
case INBOX_TYPES.TWITTER:
return 'twitter';
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp') ? 'whatsapp' : 'sms';
case INBOX_TYPES.WHATSAPP:
return 'whatsapp';
case INBOX_TYPES.API:
return 'api';
case INBOX_TYPES.EMAIL:
return 'email';
case INBOX_TYPES.TELEGRAM:
return 'telegram';
case INBOX_TYPES.LINE:
return 'line';
case INBOX_TYPES.VOICE:
return 'voice';
default:
return 'chat';
}
};
export const getInboxClassByType = (type, phoneNumber) => {
switch (type) {
case INBOX_TYPES.WEB:
return 'globe-desktop';
case INBOX_TYPES.FB:
return 'brand-facebook';
case INBOX_TYPES.TWITTER:
return 'brand-twitter';
case INBOX_TYPES.TWILIO:
return phoneNumber?.startsWith('whatsapp')
? 'brand-whatsapp'
: 'brand-sms';
case INBOX_TYPES.WHATSAPP:
return 'brand-whatsapp';
case INBOX_TYPES.API:
return 'cloud';
case INBOX_TYPES.EMAIL:
return 'mail';
case INBOX_TYPES.TELEGRAM:
return 'brand-telegram';
case INBOX_TYPES.LINE:
return 'brand-line';
case INBOX_TYPES.INSTAGRAM:
return 'brand-instagram';
case INBOX_TYPES.TIKTOK:
return 'brand-tiktok';
case INBOX_TYPES.VOICE:
return 'phone';
default:
return 'chat';
}
};
export const getInboxIconByType = (type, medium, variant = 'fill') => {
const iconMap =
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
const defaultIcon =
variant === 'fill' ? DEFAULT_ICON_FILL : DEFAULT_ICON_LINE;
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO && medium === 'whatsapp') {
return iconMap[INBOX_TYPES.WHATSAPP];
}
return iconMap[type] ?? defaultIcon;
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {
return 'warning';
}
return '';
};

View File

@@ -0,0 +1,8 @@
export const getRandomColor = () => {
const letters = '0123456789ABCDEF';
let color = '#';
for (let i = 0; i < 6; i += 1) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
};

View File

@@ -0,0 +1,55 @@
export const hasPermissions = (
requiredPermissions = [],
availablePermissions = []
) => {
return requiredPermissions.some(permission =>
availablePermissions.includes(permission)
);
};
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
return accounts.find(account => Number(account.id) === Number(accountId));
};
export const getUserPermissions = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
return currentAccount.permissions || [];
};
export const getUserRole = (user, accountId) => {
const currentAccount = getCurrentAccount(user, accountId) || {};
if (currentAccount.custom_role_id) {
return 'custom_role';
}
return currentAccount.role || 'agent';
};
/**
* Filters and transforms items based on user permissions.
*
* @param {Object} items - An object containing items to be filtered.
* @param {Array} userPermissions - Array of permissions the user has.
* @param {Function} getPermissions - Function to extract required permissions from an item.
* @param {Function} [transformItem] - Optional function to transform each item after filtering.
* @returns {Array} Filtered and transformed items.
*/
export const filterItemsByPermission = (
items,
userPermissions,
getPermissions,
transformItem = (key, item) => ({ key, ...item })
) => {
// Helper function to check if an item has the required permissions
const hasRequiredPermissions = item => {
const requiredPermissions = getPermissions(item);
return (
requiredPermissions.length === 0 ||
hasPermissions(requiredPermissions, userPermissions)
);
};
return Object.entries(items)
.filter(([, item]) => hasRequiredPermissions(item)) // Keep only items with required permissions
.map(([key, item]) => transformItem(key, item)); // Transform each remaining item
};

View File

@@ -0,0 +1,155 @@
/**
* Formats a custom domain with https protocol if needed
* @param {string} customDomain - The custom domain to format
* @returns {string} Formatted domain with https protocol
*/
const formatCustomDomain = customDomain =>
customDomain.startsWith('https') ? customDomain : `https://${customDomain}`;
/**
* Gets the default base URL from configuration
* @returns {string} The default base URL
* @throws {Error} If no valid base URL is found
*/
const getDefaultBaseURL = () => {
const { hostURL, helpCenterURL } = window.chatwootConfig || {};
const baseURL = helpCenterURL || hostURL || '';
if (!baseURL) {
throw new Error('No valid base URL found in configuration');
}
return baseURL;
};
/**
* Gets the base URL from configuration or custom domain
* @param {string} [customDomain] - Optional custom domain for the portal
* @returns {string} The base URL for the portal
*/
const getPortalBaseURL = customDomain =>
customDomain ? formatCustomDomain(customDomain) : getDefaultBaseURL();
/**
* Builds a portal URL using the provided portal slug and optional custom domain
* @param {string} portalSlug - The slug identifier for the portal
* @param {string} [customDomain] - Optional custom domain for the portal
* @returns {string} The complete portal URL
* @throws {Error} If portalSlug is not provided or invalid
*/
export const buildPortalURL = (portalSlug, customDomain) => {
const baseURL = getPortalBaseURL(customDomain);
return `${baseURL}/hc/${portalSlug}`;
};
export const buildPortalArticleURL = (
portalSlug,
categorySlug,
locale,
articleSlug,
customDomain
) => {
const portalURL = buildPortalURL(portalSlug, customDomain);
return `${portalURL}/articles/${articleSlug}`;
};
export const getArticleStatus = status => {
switch (status) {
case 'draft':
return 0;
case 'published':
return 1;
case 'archived':
return 2;
default:
return undefined;
}
};
export const ARTICLE_STATUSES = {
DRAFT: 'draft',
PUBLISHED: 'published',
ARCHIVED: 'archived',
};
export const ARTICLE_MENU_ITEMS = {
publish: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.PUBLISH',
value: ARTICLE_STATUSES.PUBLISHED,
action: 'publish',
icon: 'i-lucide-check',
},
draft: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DRAFT',
value: ARTICLE_STATUSES.DRAFT,
action: 'draft',
icon: 'i-lucide-pencil-line',
},
archive: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.ARCHIVE',
value: ARTICLE_STATUSES.ARCHIVED,
action: 'archive',
icon: 'i-lucide-archive-restore',
},
delete: {
label: 'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.DROPDOWN_MENU.DELETE',
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
};
export const ARTICLE_MENU_OPTIONS = {
[ARTICLE_STATUSES.ARCHIVED]: ['publish', 'draft'],
[ARTICLE_STATUSES.DRAFT]: ['publish', 'archive'],
[ARTICLE_STATUSES.PUBLISHED]: ['draft', 'archive'],
};
export const ARTICLE_TABS = {
ALL: 'all',
MINE: 'mine',
DRAFT: 'draft',
ARCHIVED: 'archived',
};
export const CATEGORY_ALL = 'all';
export const ARTICLE_TABS_OPTIONS = [
{
key: 'ALL',
value: 'all',
},
{
key: 'MINE',
value: 'mine',
},
{
key: 'DRAFT',
value: 'draft',
},
{
key: 'ARCHIVED',
value: 'archived',
},
];
export const LOCALE_MENU_ITEMS = [
{
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
action: 'change-default',
value: 'default',
icon: 'i-lucide-star',
},
{
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
value: 'delete',
icon: 'i-lucide-trash',
},
];
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
published: ['archive', 'draft'],
archived: ['draft'],
draft: ['archive'],
};

View File

@@ -0,0 +1,98 @@
import i18n from 'widget/i18n/index';
const defaultTranslations = Object.fromEntries(
Object.entries(i18n).filter(([key]) => key.includes('en'))
).en;
export const standardFieldKeys = {
emailAddress: {
key: 'EMAIL_ADDRESS',
label: 'Email Id',
placeholder: 'Please enter your email address',
},
fullName: {
key: 'FULL_NAME',
label: 'Full Name',
placeholder: 'Please enter your full name',
},
phoneNumber: {
key: 'PHONE_NUMBER',
label: 'Phone Number',
placeholder: 'Please enter your phone number',
},
};
export const getLabel = ({ key, label }) => {
return defaultTranslations.PRE_CHAT_FORM.FIELDS[key]
? defaultTranslations.PRE_CHAT_FORM.FIELDS[key].LABEL
: label;
};
export const getPlaceHolder = ({ key, placeholder }) => {
return defaultTranslations.PRE_CHAT_FORM.FIELDS[key]
? defaultTranslations.PRE_CHAT_FORM.FIELDS[key].PLACEHOLDER
: placeholder;
};
export const getCustomFields = ({ standardFields, customAttributes }) => {
let customFields = [];
const { pre_chat_fields: preChatFields } = standardFields;
customAttributes.forEach(attribute => {
const itemExist = preChatFields.find(
item => item.name === attribute.attribute_key
);
if (!itemExist) {
customFields.push({
label: attribute.attribute_display_name,
placeholder: attribute.attribute_display_name,
name: attribute.attribute_key,
type: attribute.attribute_display_type,
values: attribute.attribute_values,
field_type: attribute.attribute_model,
regex_pattern: attribute.regex_pattern,
regex_cue: attribute.regex_cue,
required: false,
enabled: false,
});
}
});
return customFields;
};
export const getFormattedPreChatFields = ({ preChatFields }) => {
return preChatFields.map(item => {
return {
...item,
label: getLabel({
key: item.name,
label: item.label ? item.label : item.name,
}),
placeholder: getPlaceHolder({
key: item.name,
placeholder: item.placeholder ? item.placeholder : item.name,
}),
};
});
};
export const getPreChatFields = ({
preChatFormOptions = {},
customAttributes = [],
}) => {
const { pre_chat_message, pre_chat_fields } = preChatFormOptions;
let customFields = {};
let preChatFields = {};
const formattedPreChatFields = getFormattedPreChatFields({
preChatFields: pre_chat_fields,
});
customFields = getCustomFields({
standardFields: { pre_chat_fields: formattedPreChatFields },
customAttributes,
});
preChatFields = [...formattedPreChatFields, ...customFields];
return {
pre_chat_message,
pre_chat_fields: preChatFields,
};
};

View File

@@ -0,0 +1,92 @@
/* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth';
import { useAlert } from 'dashboard/composables';
export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) {
// Service Worker isn't supported on this browser, disable or hide UI.
return;
}
if (!('PushManager' in window)) {
// Push isn't supported on this browser, disable or hide UI.
return;
}
navigator.serviceWorker
.register('/sw.js')
.then(registration => callback(registration))
.catch(registrationError => {
// eslint-disable-next-line
console.log('SW registration failed: ', registrationError);
});
};
export const hasPushPermissions = () => {
if ('Notification' in window) {
return Notification.permission === 'granted';
}
return false;
};
const generateKeys = str =>
btoa(String.fromCharCode.apply(null, new Uint8Array(str)))
.replace(/\+/g, '-')
.replace(/\//g, '_');
export const getPushSubscriptionPayload = subscription => ({
subscription_type: 'browser_push',
subscription_attributes: {
endpoint: subscription.endpoint,
p256dh: generateKeys(subscription.getKey('p256dh')),
auth: generateKeys(subscription.getKey('auth')),
},
});
export const sendRegistrationToServer = subscription => {
if (auth.hasAuthCookie()) {
return NotificationSubscriptions.create(
getPushSubscriptionPayload(subscription)
);
}
return null;
};
export const registerSubscription = (onSuccess = () => {}) => {
if (!window.chatwootConfig.vapidPublicKey) {
return;
}
navigator.serviceWorker.ready
.then(serviceWorkerRegistration =>
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: window.chatwootConfig.vapidPublicKey,
})
)
.then(sendRegistrationToServer)
.then(() => {
onSuccess();
})
.catch(error => {
// eslint-disable-next-line no-console
console.error('Push subscription registration failed:', error);
useAlert('This browser does not support desktop notification');
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
// eslint-disable-next-line no-console
console.warn('Notification is not supported');
useAlert('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);
} else if (Notification.permission !== 'denied') {
Notification.requestPermission(permission => {
if (permission === 'granted') {
registerSubscription(onSuccess);
}
});
}
};

View File

@@ -0,0 +1,333 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
import DOMPurify from 'dompurify';
/**
* Extracts plain text from HTML content
* @param {string} html - HTML content to convert
* @returns {string} Plain text content
*/
export const extractPlainTextFromHtml = html => {
if (!html) {
return '';
}
if (typeof document === 'undefined') {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = DOMPurify.sanitize(html);
return tempDiv.textContent || tempDiv.innerText || '';
};
/**
* Extracts sender name from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender name
*/
export const getEmailSenderName = (lastEmail, contact) => {
const senderName = lastEmail?.sender?.name;
if (senderName && senderName.trim()) {
return senderName.trim();
}
const contactName = contact?.name;
return contactName && contactName.trim() ? contactName.trim() : '';
};
/**
* Extracts sender email from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender email address
*/
export const getEmailSenderEmail = (lastEmail, contact) => {
const senderEmail = lastEmail?.sender?.email;
if (senderEmail && senderEmail.trim()) {
return senderEmail.trim();
}
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) {
const fromAddress = emailMeta.from[0];
if (fromAddress && fromAddress.trim()) {
return fromAddress.trim();
}
}
const contactEmail = contact?.email;
return contactEmail && contactEmail.trim() ? contactEmail.trim() : '';
};
/**
* Extracts date from email message
* @param {Object} lastEmail - Last email message object
* @returns {Date|null} Email date
*/
export const getEmailDate = lastEmail => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (emailMeta.date) {
const parsedDate = parseISO(emailMeta.date);
if (isValidDate(parsedDate)) {
return parsedDate;
}
}
const createdAt = lastEmail?.created_at;
if (createdAt) {
const timestamp = Number(createdAt);
if (!Number.isNaN(timestamp)) {
const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000;
const derivedDate = new Date(milliseconds);
if (!Number.isNaN(derivedDate.getTime())) {
return derivedDate;
}
}
}
return null;
};
/**
* Formats date for quoted email header
* @param {Date} date - Date to format
* @returns {string} Formatted date string
*/
export const formatQuotedEmailDate = date => {
try {
return format(date, "EEE, MMM d, yyyy 'at' p");
} catch (error) {
const fallbackDate = new Date(date);
if (!Number.isNaN(fallbackDate.getTime())) {
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
}
}
return '';
};
/**
* Extracts inbox email address from last email message
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Inbox email address
*/
export const getInboxEmail = (lastEmail, inbox) => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) {
const toAddress = emailMeta.to[0];
if (toAddress && toAddress.trim()) {
return toAddress.trim();
}
}
const inboxEmail = inbox?.email;
return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : '';
};
/**
* Builds quoted email header from contact (for incoming messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const senderEmail = getEmailSenderEmail(lastEmail, contact);
if (!quotedDate || !senderEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const senderName = getEmailSenderName(lastEmail, contact);
const hasName = !!senderName;
const contactLabel = hasName
? `${senderName} <${senderEmail}>`
: `<${senderEmail}>`;
return `On ${formattedDate} ${contactLabel} wrote:`;
};
/**
* Builds quoted email header from inbox (for outgoing messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const inboxEmail = getInboxEmail(lastEmail, inbox);
if (!quotedDate || !inboxEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const inboxName = inbox?.name;
const hasName = !!inboxName;
const inboxLabel = hasName
? `${inboxName} <${inboxEmail}>`
: `<${inboxEmail}>`;
return `On ${formattedDate} ${inboxLabel} wrote:`;
};
/**
* Builds quoted email header based on message type
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => {
if (!lastEmail) {
return '';
}
// MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0
const isOutgoing = lastEmail.message_type === 1;
if (isOutgoing) {
return buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
}
return buildQuotedEmailHeaderFromContact(lastEmail, contact);
};
/**
* Formats text as markdown blockquote
* @param {string} text - Text to format
* @param {string} header - Optional header to prepend
* @returns {string} Formatted blockquote
*/
export const formatQuotedTextAsBlockquote = (text, header = '') => {
const normalizedLines = text
? String(text).replace(/\r\n/g, '\n').split('\n')
: [];
if (!header && !normalizedLines.length) {
return '';
}
const quotedLines = [];
if (header) {
quotedLines.push(`> ${header}`);
quotedLines.push('>');
}
normalizedLines.forEach(line => {
const trimmedLine = line.trimEnd();
quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>');
});
return quotedLines.join('\n');
};
/**
* Extracts quoted email text from last email message
* @param {Object} lastEmail - Last email message object
* @returns {string} Quoted email text
*/
export const extractQuotedEmailText = lastEmail => {
if (!lastEmail) {
return '';
}
const contentAttributes =
lastEmail.contentAttributes || lastEmail.content_attributes || {};
const emailContent = contentAttributes.email || {};
const textContent = emailContent.textContent || emailContent.text_content;
if (textContent?.reply) {
return textContent.reply;
}
if (textContent?.full) {
return textContent.full;
}
const htmlContent = emailContent.htmlContent || emailContent.html_content;
if (htmlContent?.reply) {
return extractPlainTextFromHtml(htmlContent.reply);
}
if (htmlContent?.full) {
return extractPlainTextFromHtml(htmlContent.full);
}
const fallbackContent =
lastEmail.content || lastEmail.processed_message_content || '';
return fallbackContent;
};
/**
* Truncates text for preview display
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length (default: 80)
* @returns {string} Truncated text
*/
export const truncatePreviewText = (text, maxLength = 80) => {
const preview = text.trim().replace(/\s+/g, ' ');
if (!preview) {
return '';
}
if (preview.length <= maxLength) {
return preview;
}
return `${preview.slice(0, maxLength - 3)}...`;
};
/**
* Appends quoted text to message
* @param {string} message - Original message
* @param {string} quotedText - Text to quote
* @param {string} header - Quote header
* @returns {string} Message with quoted text appended
*/
export const appendQuotedTextToMessage = (message, quotedText, header) => {
const baseMessage = message ? String(message) : '';
const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header);
if (!quotedBlock) {
return baseMessage;
}
if (!baseMessage) {
return quotedBlock;
}
let separator = '\n\n';
if (baseMessage.endsWith('\n\n')) {
separator = '';
} else if (baseMessage.endsWith('\n')) {
separator = '\n';
}
return `${baseMessage}${separator}${quotedBlock}`;
};

View File

@@ -0,0 +1,146 @@
import {
hasPermissions,
getUserPermissions,
getCurrentAccount,
} from './permissionsHelper';
import {
ROLES,
CONVERSATION_PERMISSIONS,
CONTACT_PERMISSIONS,
REPORTS_PERMISSIONS,
PORTAL_PERMISSIONS,
} from 'dashboard/constants/permissions.js';
export const routeIsAccessibleFor = (route, userPermissions = []) => {
const { meta: { permissions: routePermissions = [] } = {} } = route;
return hasPermissions(routePermissions, userPermissions);
};
export const defaultRedirectPage = (to, permissions) => {
const { accountId } = to.params;
const permissionRoutes = [
{
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
path: 'dashboard',
},
{ permissions: [CONTACT_PERMISSIONS], path: 'contacts' },
{ permissions: [REPORTS_PERMISSIONS], path: 'reports/overview' },
{ permissions: [PORTAL_PERMISSIONS], path: 'portals' },
];
const route = permissionRoutes.find(({ permissions: routePermissions }) =>
hasPermissions(routePermissions, permissions)
);
return `accounts/${accountId}/${route ? route.path : 'dashboard'}`;
};
const validateActiveAccountRoutes = (to, user) => {
// If the current account is active, then check for the route permissions
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
// If the user is trying to access suspended route, redirect them to dashboard
if (to.name === 'account_suspended') {
return accountDashboardURL;
}
const userPermissions = getUserPermissions(user, to.params.accountId);
const isAccessible = routeIsAccessibleFor(to, userPermissions);
// If the route is not accessible for the user, return to dashboard screen
return isAccessible ? null : defaultRedirectPage(to, userPermissions);
};
export const validateLoggedInRoutes = (to, user) => {
const currentAccount = getCurrentAccount(user, Number(to.params.accountId));
// If current account is missing, either user does not have
// access to the account or the account is deleted, return to login screen
if (!currentAccount) {
return `app/login`;
}
const isCurrentAccountActive = currentAccount.status === 'active';
if (isCurrentAccountActive) {
return validateActiveAccountRoutes(to, user);
}
// If the current account is not active, then redirect the user to the suspended screen
if (to.name !== 'account_suspended') {
return `accounts/${to.params.accountId}/suspended`;
}
// Proceed to the route if none of the above conditions are met
return null;
};
export const isAConversationRoute = (
routeName,
includeBase = false,
includeExtended = true
) => {
const baseRoutes = [
'home',
'conversation_mentions',
'conversation_unattended',
'inbox_dashboard',
'label_conversations',
'team_conversations',
'folder_conversations',
'conversation_participating',
];
const extendedRoutes = [
'inbox_conversation',
'conversation_through_mentions',
'conversation_through_unattended',
'conversation_through_inbox',
'conversations_through_label',
'conversations_through_team',
'conversations_through_folders',
'conversation_through_participating',
];
const routes = [
...(includeBase ? baseRoutes : []),
...(includeExtended ? extendedRoutes : []),
];
return routes.includes(routeName);
};
export const getConversationDashboardRoute = routeName => {
switch (routeName) {
case 'inbox_conversation':
return 'home';
case 'conversation_through_mentions':
return 'conversation_mentions';
case 'conversation_through_unattended':
return 'conversation_unattended';
case 'conversations_through_label':
return 'label_conversations';
case 'conversations_through_team':
return 'team_conversations';
case 'conversations_through_folders':
return 'folder_conversations';
case 'conversation_through_participating':
return 'conversation_participating';
case 'conversation_through_inbox':
return 'inbox_dashboard';
default:
return null;
}
};
export const isAInboxViewRoute = (routeName, includeBase = false) => {
const baseRoutes = ['inbox_view'];
const extendedRoutes = ['inbox_view_conversation'];
const routeNames = includeBase
? [...baseRoutes, ...extendedRoutes]
: extendedRoutes;
return routeNames.includes(routeName);
};
export const isNotificationRoute = routeName =>
routeName === 'notifications_index';

View File

@@ -0,0 +1,60 @@
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 initializeAnalyticsEvents = () => {
AnalyticsHelper.init();
emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user);
});
};
export const initializeAudioAlerts = user => {
const { ui_settings: uiSettings } = user || {};
const {
always_play_audio_alert: alwaysPlayAudioAlert,
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
// UI Settings can be undefined initially as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
DashboardAudioNotificationHelper.set({
currentUser: user,
audioAlertType: audioAlertType || 'none',
audioAlertTone: audioAlertTone || 'ding',
alwaysPlayAudioAlert: alwaysPlayAudioAlert || false,
alertIfUnreadConversationExist: alertIfUnreadConversationExist || false,
});
};
export const initializeChatwootEvents = () => {
emitter.on(CHATWOOT_RESET, () => {
if (window.$chatwoot) {
window.$chatwoot.reset();
}
});
emitter.on(CHATWOOT_SET_USER, ({ user }) => {
if (window.$chatwoot) {
window.$chatwoot.setUser(user.email, {
avatar_url: user.avatar_url,
email: user.email,
identifier_hash: user.hmac_identifier,
name: user.name,
});
window.$chatwoot.setCustomAttributes({
signedUpAt: user.created_at,
cloudCustomer: 'true',
account_id: user.account_id,
});
}
initializeAudioAlerts(user);
});
};

View File

@@ -0,0 +1,97 @@
import {
getUnixTime,
format,
add,
startOfWeek,
addWeeks,
startOfMonth,
isMonday,
isToday,
setHours,
setMinutes,
setSeconds,
} from 'date-fns';
import wootConstants from 'dashboard/constants/globals';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const findStartOfNextWeek = currentDate => {
const startOfNextWeek = startOfWeek(addWeeks(currentDate, 1));
return isMonday(startOfNextWeek)
? startOfNextWeek
: add(startOfNextWeek, {
days: (8 - startOfNextWeek.getDay()) % 7,
});
};
export const findStartOfNextMonth = currentDate => {
const startOfNextMonth = startOfMonth(add(currentDate, { months: 1 }));
return isMonday(startOfNextMonth)
? startOfNextMonth
: add(startOfNextMonth, {
days: (8 - startOfNextMonth.getDay()) % 7,
});
};
export const findNextDay = currentDate => {
return add(currentDate, { days: 1 });
};
export const setHoursToNine = date => {
return setSeconds(setMinutes(setHours(date, 9), 0), 0);
};
export const findSnoozeTime = (snoozeType, currentDate = new Date()) => {
let parsedDate = null;
if (snoozeType === SNOOZE_OPTIONS.AN_HOUR_FROM_NOW) {
parsedDate = add(currentDate, { hours: 1 });
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_TOMORROW) {
parsedDate = setHoursToNine(findNextDay(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_WEEK) {
parsedDate = setHoursToNine(findStartOfNextWeek(currentDate));
} else if (snoozeType === SNOOZE_OPTIONS.UNTIL_NEXT_MONTH) {
parsedDate = setHoursToNine(findStartOfNextMonth(currentDate));
}
return parsedDate ? getUnixTime(parsedDate) : null;
};
export const snoozedReopenTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const date = new Date(snoozedUntil);
if (isToday(date)) {
return format(date, 'h.mmaaa');
}
return snoozedUntil ? format(date, 'd MMM, h.mmaaa') : null;
};
export const snoozedReopenTimeToTimestamp = snoozedUntil => {
return snoozedUntil ? getUnixTime(new Date(snoozedUntil)) : null;
};
export const shortenSnoozeTime = snoozedUntil => {
if (!snoozedUntil) {
return null;
}
const unitMap = {
minutes: 'm',
minute: 'm',
hours: 'h',
hour: 'h',
days: 'd',
day: 'd',
months: 'mo',
month: 'mo',
years: 'y',
year: 'y',
};
const shortenTime = snoozedUntil
.replace(/^in\s+/i, '')
.replace(
/\s(minute|hour|day|month|year)s?\b/gi,
(match, unit) => unitMap[unit.toLowerCase()] || match
);
return shortenTime;
};

View File

@@ -0,0 +1,114 @@
import { DataManager } from '../../CacheHelper/DataManager';
describe('DataManager', () => {
const accountId = 'test-account';
let dataManager;
beforeEach(async () => {
dataManager = new DataManager(accountId);
await dataManager.initDb();
});
afterEach(async () => {
const tx = dataManager.db.transaction(
dataManager.modelsToSync,
'readwrite'
);
dataManager.modelsToSync.forEach(modelName => {
tx.objectStore(modelName).clear();
});
await tx.done;
});
describe('initDb', () => {
it('should initialize the database', async () => {
expect(dataManager.db).not.toBeNull();
});
it('should return the same instance of the database', async () => {
const db1 = await dataManager.initDb();
const db2 = await dataManager.initDb();
expect(db1).toBe(db2);
});
});
describe('validateModel', () => {
it('should throw an error for empty input', async () => {
expect(() => {
dataManager.validateModel();
}).toThrow();
});
it('should throw an error for invalid model', async () => {
expect(() => {
dataManager.validateModel('invalid-model');
}).toThrow();
});
it('should not throw an error for valid model', async () => {
expect(dataManager.validateModel('label')).toBeTruthy();
});
});
describe('replace', () => {
it('should replace existing data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
const newData = [
{ id: 3, name: 'inbox-3' },
{ id: 4, name: 'inbox-4' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
await dataManager.replace({ modelName: 'inbox', data: newData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(newData);
});
});
describe('push', () => {
it('should add data to the specified model', async () => {
const inboxData = { id: 1, name: 'inbox-1' };
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual([inboxData]);
});
it('should add multiple items to the specified model if an array of data is provided', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('get', () => {
it('should return all data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('setCacheKeys', () => {
it('should add cache keys for each model', async () => {
const cacheKeys = { inbox: 'cache-key-1', label: 'cache-key-2' };
await dataManager.setCacheKeys(cacheKeys);
const result = await dataManager.getCacheKey('inbox');
expect(result).toEqual(cacheKeys.inbox);
});
});
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadScript } from '../DOMHelpers';
import { JSDOM } from 'jsdom';
describe('loadScript', () => {
let dom;
let window;
let document;
beforeEach(() => {
dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
url: 'http://localhost',
});
window = dom.window;
document = window.document;
global.document = document;
});
afterEach(() => {
vi.restoreAllMocks();
delete global.document;
});
it('should load a script successfully', async () => {
const src = 'https://example.com/script.js';
const loadPromise = loadScript(src, {});
// Simulate successful script load
setTimeout(() => {
const script = document.querySelector(`script[src="${src}"]`);
if (script) {
script.dispatchEvent(new window.Event('load'));
}
}, 0);
const script = await loadPromise;
expect(script).toBeTruthy();
expect(script.getAttribute('src')).toBe(src);
expect(script.getAttribute('data-loaded')).toBe('true');
});
it('should not load a script if document is not available', async () => {
delete global.document;
const result = await loadScript('https://example.com/script.js', {});
expect(result).toBe(false);
});
it('should use an existing script if already present', async () => {
const src = 'https://example.com/existing-script.js';
const existingScript = document.createElement('script');
existingScript.src = src;
existingScript.setAttribute('data-loaded', 'true');
document.head.appendChild(existingScript);
const script = await loadScript(src, {});
expect(script).toBe(existingScript);
});
it('should set custom attributes on the script element', async () => {
const src = 'https://example.com/custom-script.js';
const options = {
type: 'module',
async: false,
defer: true,
crossOrigin: 'anonymous',
noModule: true,
referrerPolicy: 'origin',
id: 'custom-script',
attrs: { 'data-custom': 'value' },
};
const loadPromise = loadScript(src, options);
// Simulate successful script load
setTimeout(() => {
const script = document.querySelector(`script[src="${src}"]`);
if (script) {
script.dispatchEvent(new window.Event('load'));
}
}, 0);
const script = await loadPromise;
expect(script.type).toBe('module');
expect(script.async).toBe(false);
expect(script.defer).toBe(true);
expect(script.crossOrigin).toBe('anonymous');
expect(script.noModule).toBe(true);
expect(script.referrerPolicy).toBe('origin');
expect(script.id).toBe('custom-script');
expect(script.getAttribute('data-custom')).toBe('value');
});
});

View File

@@ -0,0 +1,349 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import ReconnectService from 'dashboard/helper/ReconnectService';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
}));
vi.mock('date-fns', () => ({
differenceInSeconds: vi.fn(),
}));
vi.mock('dashboard/helper/routeHelpers', () => ({
isAConversationRoute: vi.fn(),
isAInboxViewRoute: vi.fn(),
isNotificationRoute: vi.fn(),
}));
const storeMock = {
dispatch: vi.fn(),
getters: {
getAppliedConversationFiltersQuery: [],
'customViews/getActiveConversationFolder': { query: {} },
'notifications/getNotificationFilters': {},
},
};
const routerMock = {
currentRoute: {
value: {
name: '',
params: { conversation_id: null },
},
},
};
describe('ReconnectService', () => {
let reconnectService;
beforeEach(() => {
window.addEventListener = vi.fn();
window.removeEventListener = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: vi.fn() },
});
reconnectService = new ReconnectService(storeMock, routerMock);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with store, router, and setup event listeners', () => {
expect(reconnectService.store).toBe(storeMock);
expect(reconnectService.router).toBe(routerMock);
expect(window.addEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('disconnect', () => {
it('should remove event listeners', () => {
reconnectService.disconnect();
expect(window.removeEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('getSecondsSinceDisconnect', () => {
it('should return 0 if disconnectTime is null', () => {
reconnectService.disconnectTime = null;
expect(reconnectService.getSecondsSinceDisconnect()).toBe(0);
});
it('should return the number of seconds + threshold since disconnect', () => {
reconnectService.disconnectTime = new Date();
differenceInSeconds.mockReturnValue(100);
expect(reconnectService.getSecondsSinceDisconnect()).toBe(100);
});
});
describe('handleOnlineEvent', () => {
it('should reload the page if disconnected for more than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = vi
.fn()
.mockReturnValue(10801);
reconnectService.handleOnlineEvent();
expect(window.location.reload).toHaveBeenCalled();
});
it('should not reload the page if disconnected for less than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = vi
.fn()
.mockReturnValue(10799);
reconnectService.handleOnlineEvent();
expect(window.location.reload).not.toHaveBeenCalled();
});
});
describe('fetchConversations', () => {
it('should update the filters with disconnected time and the threshold', async () => {
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
page: null,
updatedWithin: 115,
});
});
it('should dispatch updateChatListFilters and fetchAllConversations', async () => {
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
page: null,
updatedWithin: 115,
});
expect(storeMock.dispatch).toHaveBeenCalledWith('fetchAllConversations');
});
it('should dispatch updateChatListFilters and reset updatedWithin', async () => {
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
updatedWithin: null,
});
});
});
describe('fetchFilteredOrSavedConversations', () => {
it('should dispatch fetchFilteredConversations', async () => {
const payload = { test: 'data' };
await reconnectService.fetchFilteredOrSavedConversations(payload);
expect(storeMock.dispatch).toHaveBeenCalledWith(
'fetchFilteredConversations',
{ queryData: payload, page: 1 }
);
});
});
describe('fetchConversationsOnReconnect', () => {
it('should fetch filtered or saved conversations if query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
},
],
};
const spy = vi.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith(
storeMock.getters.getAppliedConversationFiltersQuery
);
});
it('should fetch all conversations if no query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: null,
};
const spy = vi.spyOn(reconnectService, 'fetchConversations');
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalled();
});
it('should fetch filtered or saved conversations if active folder query exists and no applied query', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: { test: 'activeFolderQuery' },
};
const spy = vi.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith({ test: 'activeFolderQuery' });
});
});
describe('fetchConversationMessagesOnReconnect', () => {
it('should dispatch syncActiveConversationMessages if conversationId exists', async () => {
routerMock.currentRoute.value.params.conversation_id = 1;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'syncActiveConversationMessages',
{ conversationId: 1 }
);
});
it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => {
routerMock.currentRoute.value.params.conversation_id = null;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'syncActiveConversationMessages',
expect.anything()
);
});
});
describe('fetchNotificationsOnReconnect', () => {
it('should dispatch notifications/index', async () => {
const filter = { test: 'filter' };
await reconnectService.fetchNotificationsOnReconnect(filter);
expect(storeMock.dispatch).toHaveBeenCalledWith('notifications/index', {
...filter,
page: 1,
});
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
const spyConversations = vi.spyOn(
reconnectService,
'fetchConversationsOnReconnect'
);
const spyMessages = vi.spyOn(
reconnectService,
'fetchConversationMessagesOnReconnect'
);
await reconnectService.handleRouteSpecificFetch();
expect(spyConversations).toHaveBeenCalled();
expect(spyMessages).toHaveBeenCalled();
});
it('should fetch notifications if current route is an inbox view route', async () => {
isAInboxViewRoute.mockReturnValue(true);
const spy = vi.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
it('should fetch notifications if current route is a notification route', async () => {
isNotificationRoute.mockReturnValue(true);
const spy = vi.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
});
describe('setConversationLastMessageId', () => {
it('should dispatch setConversationLastMessageId if conversationId exists', async () => {
routerMock.currentRoute.value.params.conversation_id = 1;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'setConversationLastMessageId',
{ conversationId: 1 }
);
});
it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => {
routerMock.currentRoute.value.params.conversation_id = null;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'setConversationLastMessageId',
expect.anything()
);
});
});
describe('onDisconnect', () => {
it('should set disconnectTime and call setConversationLastMessageId', () => {
reconnectService.setConversationLastMessageId = vi.fn();
reconnectService.onDisconnect();
expect(reconnectService.disconnectTime).toBeInstanceOf(Date);
expect(reconnectService.setConversationLastMessageId).toHaveBeenCalled();
});
});
describe('onReconnect', () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = vi.fn();
reconnectService.revalidateCaches = vi.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
});
});
});

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import Timer from '../Timer';
describe('Timer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('constructor', () => {
it('initializes with elapsed 0 and no interval', () => {
const timer = new Timer();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('accepts an onTick callback', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
expect(timer.onTick).toBe(onTick);
});
});
describe('start', () => {
it('starts the timer and increments elapsed every second', () => {
const timer = new Timer();
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(1);
vi.advanceTimersByTime(1000);
expect(timer.elapsed).toBe(2);
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(5);
});
it('calls onTick callback with elapsed value', () => {
const onTick = vi.fn();
const timer = new Timer(onTick);
timer.start();
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(1);
vi.advanceTimersByTime(1000);
expect(onTick).toHaveBeenCalledWith(2);
expect(onTick).toHaveBeenCalledTimes(2);
});
it('resets elapsed to 0 when restarted', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(5);
timer.start();
expect(timer.elapsed).toBe(0);
vi.advanceTimersByTime(2000);
expect(timer.elapsed).toBe(2);
});
it('clears previous interval when restarted', () => {
const timer = new Timer();
timer.start();
const firstIntervalId = timer.intervalId;
timer.start();
expect(timer.intervalId).not.toBe(firstIntervalId);
});
});
describe('stop', () => {
it('stops the timer and resets elapsed to 0', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(3000);
expect(timer.elapsed).toBe(3);
timer.stop();
expect(timer.elapsed).toBe(0);
expect(timer.intervalId).toBeNull();
});
it('prevents further increments after stopping', () => {
const timer = new Timer();
timer.start();
vi.advanceTimersByTime(2000);
timer.stop();
vi.advanceTimersByTime(5000);
expect(timer.elapsed).toBe(0);
});
it('handles stop when timer is not running', () => {
const timer = new Timer();
expect(() => timer.stop()).not.toThrow();
expect(timer.elapsed).toBe(0);
});
});
});

View File

@@ -0,0 +1,350 @@
import {
frontendURL,
conversationUrl,
isValidURL,
conversationListPageURL,
getArticleSearchURL,
hasValidAvatarUrl,
timeStampAppendedURL,
getHostNameFromURL,
extractFilenameFromUrl,
sanitizeAllowedDomains,
} from '../URLHelper';
describe('#URL Helpers', () => {
describe('conversationListPageURL', () => {
it('should return url to dashboard', () => {
expect(conversationListPageURL({ accountId: 1 })).toBe(
'/app/accounts/1/dashboard'
);
});
it('should return url to inbox', () => {
expect(conversationListPageURL({ accountId: 1, inboxId: 1 })).toBe(
'/app/accounts/1/inbox/1'
);
});
it('should return url to label', () => {
expect(conversationListPageURL({ accountId: 1, label: 'support' })).toBe(
'/app/accounts/1/label/support'
);
});
it('should return url to team', () => {
expect(conversationListPageURL({ accountId: 1, teamId: 1 })).toBe(
'/app/accounts/1/team/1'
);
});
it('should return url to custom view', () => {
expect(conversationListPageURL({ accountId: 1, customViewId: 1 })).toBe(
'/app/accounts/1/custom_view/1'
);
});
});
describe('conversationUrl', () => {
it('should return direct conversation URL if activeInbox is nil', () => {
expect(conversationUrl({ accountId: 1, id: 1 })).toBe(
'accounts/1/conversations/1'
);
});
it('should return inbox conversation URL if activeInbox is not nil', () => {
expect(conversationUrl({ accountId: 1, id: 1, activeInbox: 2 })).toBe(
'accounts/1/inbox/2/conversations/1'
);
});
it('should return correct conversation URL if label is active', () => {
expect(
conversationUrl({ accountId: 1, label: 'customer-support', id: 1 })
).toBe('accounts/1/label/customer-support/conversations/1');
});
it('should return correct conversation URL if team Id is available', () => {
expect(conversationUrl({ accountId: 1, teamId: 1, id: 1 })).toBe(
'accounts/1/team/1/conversations/1'
);
});
});
describe('frontendURL', () => {
it('should return url without params if params passed is nil', () => {
expect(frontendURL('main', null)).toBe('/app/main');
});
it('should return url without params if params passed is not nil', () => {
expect(frontendURL('main', { ping: 'pong' })).toBe('/app/main?ping=pong');
});
});
describe('isValidURL', () => {
it('should return true if valid url is passed', () => {
expect(isValidURL('https://chatwoot.com')).toBe(true);
});
it('should return false if invalid url is passed', () => {
expect(isValidURL('alert.window')).toBe(false);
});
});
describe('getArticleSearchURL', () => {
it('should generate a basic URL without optional parameters', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
it('should include status parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
status: 'published',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&status=published'
);
});
it('should include author_id parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
authorId: 123,
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&author_id=123'
);
});
it('should include category_slug parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
categorySlug: 'technology',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&category_slug=technology'
);
});
it('should include sort parameter if provided', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
sort: 'views',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en&sort=views');
});
it('should handle multiple optional parameters', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
status: 'draft',
authorId: 456,
categorySlug: 'science',
sort: 'views',
host: 'myurl.com',
});
expect(url).toBe(
'myurl.com/news/articles?page=1&locale=en&status=draft&author_id=456&category_slug=science&sort=views'
);
});
it('should handle missing optional parameters gracefully', () => {
const url = getArticleSearchURL({
portalSlug: 'news',
pageNumber: 1,
locale: 'en',
host: 'myurl.com',
});
expect(url).toBe('myurl.com/news/articles?page=1&locale=en');
});
});
describe('hasValidAvatarUrl', () => {
test('should return true for valid non-Gravatar URL', () => {
expect(hasValidAvatarUrl('https://chatwoot.com/avatar.jpg')).toBe(true);
});
test('should return false for a Gravatar URL (www.gravatar.com)', () => {
expect(hasValidAvatarUrl('https://www.gravatar.com/avatar.jpg')).toBe(
false
);
});
test('should return false for a Gravatar URL (gravatar)', () => {
expect(hasValidAvatarUrl('https://gravatar/avatar.jpg')).toBe(false);
});
test('should handle invalid URL', () => {
expect(hasValidAvatarUrl('invalid-url')).toBe(false); // or expect an error, depending on function design
});
test('should return false for empty or undefined URL', () => {
expect(hasValidAvatarUrl('')).toBe(false);
expect(hasValidAvatarUrl()).toBe(false);
});
});
describe('timeStampAppendedURL', () => {
const FIXED_TIMESTAMP = 1234567890000;
beforeEach(() => {
vi.spyOn(Date, 'now').mockImplementation(() => FIXED_TIMESTAMP);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should append timestamp to a URL without query parameters', () => {
const input = 'https://example.com/audio.mp3';
const expected = `https://example.com/audio.mp3?t=${FIXED_TIMESTAMP}`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should append timestamp to a URL with existing query parameters', () => {
const input = 'https://example.com/audio.mp3?volume=50';
const expected = `https://example.com/audio.mp3?volume=50&t=${FIXED_TIMESTAMP}`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should not append timestamp if it already exists', () => {
const input = 'https://example.com/audio.mp3?t=9876543210';
expect(timeStampAppendedURL(input)).toBe(input);
});
it('should handle URLs with hash fragments', () => {
const input = 'https://example.com/audio.mp3#section1';
const expected = `https://example.com/audio.mp3?t=${FIXED_TIMESTAMP}#section1`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should handle complex URLs', () => {
const input =
'https://example.com/path/to/audio.mp3?key1=value1&key2=value2#fragment';
const expected = `https://example.com/path/to/audio.mp3?key1=value1&key2=value2&t=${FIXED_TIMESTAMP}#fragment`;
expect(timeStampAppendedURL(input)).toBe(expected);
});
it('should throw an error for invalid URLs', () => {
const input = 'not a valid url';
expect(() => timeStampAppendedURL(input)).toThrow();
});
});
describe('getHostNameFromURL', () => {
it('should return the hostname from a valid URL', () => {
expect(getHostNameFromURL('https://example.com/path')).toBe(
'example.com'
);
});
it('should return null for an invalid URL', () => {
expect(getHostNameFromURL('not a valid url')).toBe(null);
});
it('should return null for an empty string', () => {
expect(getHostNameFromURL('')).toBe(null);
});
it('should return null for undefined input', () => {
expect(getHostNameFromURL(undefined)).toBe(null);
});
it('should correctly handle URLs with non-standard TLDs', () => {
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
});
});
describe('extractFilenameFromUrl', () => {
it('should extract filename from a valid URL', () => {
expect(
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
).toBe('file.jpg');
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
'image.png'
);
expect(
extractFilenameFromUrl(
'https://example.com/folder/document.pdf?query=1'
)
).toBe('document.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.txt#section')
).toBe('file.txt');
});
it('should handle URLs without filename', () => {
expect(extractFilenameFromUrl('https://example.com/')).toBe(
'https://example.com/'
);
expect(extractFilenameFromUrl('https://example.com')).toBe(
'https://example.com'
);
});
it('should handle invalid URLs gracefully', () => {
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
});
it('should handle edge cases', () => {
expect(extractFilenameFromUrl('')).toBe('');
expect(extractFilenameFromUrl(null)).toBe(null);
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
expect(extractFilenameFromUrl(123)).toBe(123);
});
it('should handle URLs with query parameters and fragments', () => {
expect(
extractFilenameFromUrl(
'https://example.com/file.jpg?size=large&format=png'
)
).toBe('file.jpg');
expect(
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
).toBe('file.pdf');
expect(
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
).toBe('file.doc');
});
});
describe('sanitizeAllowedDomains', () => {
it('returns empty string for falsy input', () => {
expect(sanitizeAllowedDomains('')).toBe('');
expect(sanitizeAllowedDomains(null)).toBe('');
expect(sanitizeAllowedDomains(undefined)).toBe('');
});
it('trims whitespace and converts newlines to commas', () => {
const input = ' example.com \n foo.bar\nbar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('handles Windows newlines and mixed spacing', () => {
const input = ' example.com\r\n\tfoo.bar , bar.baz ';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
it('removes empty values from repeated commas', () => {
const input = ',,example.com,,foo.bar,,';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar');
});
it('lowercases entries and de-duplicates preserving order', () => {
const input = 'Example.com,FOO.bar,example.com,Bar.Baz,foo.BAR';
expect(sanitizeAllowedDomains(input)).toBe('example.com,foo.bar,bar.baz');
});
});
});

View File

@@ -0,0 +1,67 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
import ActionCableConnector from '../actionCable';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
vi.mock('dashboard/composables/useImpersonation', () => ({
useImpersonation: () => ({
isImpersonating: { value: false },
}),
}));
global.chatwootConfig = {
websocketURL: 'wss://test.chatwoot.com',
};
describe('ActionCableConnector - Copilot Tests', () => {
let store;
let actionCable;
let mockDispatch;
beforeEach(() => {
vi.clearAllMocks();
mockDispatch = vi.fn();
store = {
$store: {
dispatch: mockDispatch,
getters: {
getCurrentAccountId: 1,
},
},
};
actionCable = ActionCableConnector.init(store.$store, 'test-token');
});
describe('copilot event handlers', () => {
it('should register the copilot.message.created event handler', () => {
expect(Object.keys(actionCable.events)).toContain(
'copilot.message.created'
);
expect(actionCable.events['copilot.message.created']).toBe(
actionCable.onCopilotMessageCreated
);
});
it('should handle the copilot.message.created event through the ActionCable system', () => {
const copilotData = {
id: 2,
content: 'This is a copilot message from ActionCable',
conversation_id: 456,
created_at: '2025-05-27T15:58:04-06:00',
account_id: 1,
};
actionCable.onReceived({
event: 'copilot.message.created',
data: copilotData,
});
expect(mockDispatch).toHaveBeenCalledWith(
'copilotMessages/upsert',
copilotData
);
});
});
});

View File

@@ -0,0 +1,41 @@
import actionQueryGenerator from '../actionQueryGenerator';
const testData = [
{
action_name: 'add_label',
action_params: [{ id: 'testlabel', name: 'testlabel' }],
},
{
action_name: 'assign_team',
action_params: [
{
id: 1,
name: 'sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
],
},
];
const finalResult = [
{
action_name: 'add_label',
action_params: ['testlabel'],
},
{
action_name: 'assign_team',
action_params: [1],
},
];
describe('#actionQueryGenerator', () => {
it('returns the correct format of filter query', () => {
expect(actionQueryGenerator(testData)).toEqual(finalResult);
expect(
actionQueryGenerator(testData).every(i => Array.isArray(i.action_params))
).toBe(true);
});
});

View File

@@ -0,0 +1,94 @@
import {
getAgentsByAvailability,
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
} from '../agentHelper';
import {
allAgentsData,
onlineAgentsData,
busyAgentsData,
offlineAgentsData,
sortedByAvailability,
formattedAgentsByPresenceOnline,
formattedAgentsByPresenceOffline,
} from 'dashboard/helper/specs/fixtures/agentFixtures';
describe('agentHelper', () => {
describe('getAgentsByAvailability', () => {
it('returns agents by availability', () => {
expect(getAgentsByAvailability(allAgentsData, 'online')).toEqual(
onlineAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'busy')).toEqual(
busyAgentsData
);
expect(getAgentsByAvailability(allAgentsData, 'offline')).toEqual(
offlineAgentsData
);
});
});
describe('getSortedAgentsByAvailability', () => {
it('returns sorted agents by availability', () => {
expect(getSortedAgentsByAvailability(allAgentsData)).toEqual(
sortedByAvailability
);
});
it('returns an empty array when given an empty input', () => {
expect(getSortedAgentsByAvailability([])).toEqual([]);
});
it('maintains the order of agents with the same availability status', () => {
const result = getSortedAgentsByAvailability(allAgentsData);
expect(result[2].name).toBe('Honey Bee');
expect(result[3].name).toBe('Samuel Keta');
});
});
describe('getAgentsByUpdatedPresence', () => {
it('returns agents with updated presence', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOffline);
});
it('does not modify other agents presence', () => {
const currentUser = {
id: 2,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence(
formattedAgentsByPresenceOnline,
currentUser,
currentAccountId
)
).toEqual(formattedAgentsByPresenceOnline);
});
it('handles empty agent list', () => {
const currentUser = {
id: 1,
accounts: [{ id: 1, availability_status: 'offline' }],
};
const currentAccountId = 1;
expect(
getAgentsByUpdatedPresence([], currentUser, currentAccountId)
).toEqual([]);
});
});
});

View File

@@ -0,0 +1,194 @@
import {
extractChangedAccountUserValues,
generateTranslationPayload,
generateLogActionKey,
} from '../auditlogHelper'; // import the functions
describe('Helper functions', () => {
const agentList = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
{ id: 3, name: 'Agent 3' },
];
describe('extractChangedAccountUserValues', () => {
it('should correctly extract values when role is changed', () => {
const changes = {
role: [0, 1],
};
const { changes: extractedChanges, values } =
extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['role']);
expect(values).toEqual(['administrator']);
});
it('should correctly extract values when availability is changed', () => {
const changes = {
availability: [0, 2],
};
const { changes: extractedChanges, values } =
extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['availability']);
expect(values).toEqual(['busy']);
});
it('should correctly extract values when both are changed', () => {
const changes = {
role: [1, 0],
availability: [1, 2],
};
const { changes: extractedChanges, values } =
extractChangedAccountUserValues(changes);
expect(extractedChanges).toEqual(['role', 'availability']);
expect(values).toEqual(['agent', 'busy']);
});
});
describe('generateTranslationPayload', () => {
it('should handle AccountUser create', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'create',
user_id: 1,
auditable_id: 123,
audited_changes: {
user_id: 2,
role: 1,
},
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 123,
invitee: 'Agent 2',
role: 'administrator',
});
});
it('should handle AccountUser update', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable_id: 123,
audited_changes: {
user_id: 2,
role: [1, 0],
availability: [0, 2],
},
auditable: {
user_id: 3,
},
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 123,
user: 'Agent 3',
attributes: ['role', 'availability'],
values: ['agent', 'busy'],
});
});
it('should handle InboxMember or TeamMember', () => {
const auditLogItemInboxMember = {
auditable_type: 'InboxMember',
action: 'create',
audited_changes: {
user_id: 2,
},
user_id: 1,
auditable_id: 789,
};
const payloadInboxMember = generateTranslationPayload(
auditLogItemInboxMember,
agentList
);
expect(payloadInboxMember).toEqual({
agentName: 'Agent 1',
id: 789,
user: 'Agent 2',
});
const auditLogItemTeamMember = {
auditable_type: 'TeamMember',
action: 'create',
audited_changes: {
user_id: 3,
},
user_id: 1,
auditable_id: 789,
};
const payloadTeamMember = generateTranslationPayload(
auditLogItemTeamMember,
agentList
);
expect(payloadTeamMember).toEqual({
agentName: 'Agent 1',
id: 789,
user: 'Agent 3',
});
});
it('should handle generic case like Team create', () => {
const auditLogItem = {
auditable_type: 'Team',
action: 'create',
user_id: 1,
auditable_id: 456,
};
const payload = generateTranslationPayload(auditLogItem, agentList);
expect(payload).toEqual({
agentName: 'Agent 1',
id: 456,
});
});
});
describe('generateLogActionKey', () => {
it('should generate correct action key when user updates self', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable: {
user_id: 1,
},
};
const logActionKey = generateLogActionKey(auditLogItem);
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF');
});
it('should generate correct action key when user updates other agent', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable: {
user_id: 2,
},
};
const logActionKey = generateLogActionKey(auditLogItem);
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER');
});
it('should generate correct action key when updating a deleted user', () => {
const auditLogItem = {
auditable_type: 'AccountUser',
action: 'update',
user_id: 1,
auditable: null,
};
const logActionKey = generateLogActionKey(auditLogItem);
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED');
});
});
});

View File

@@ -0,0 +1,455 @@
import * as helpers from 'dashboard/helper/automationHelper';
import {
OPERATOR_TYPES_1,
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
customAttributes,
labels,
automation,
contactAttrs,
conversationAttrs,
expectedOutputForCustomAttributeGenerator,
} from './fixtures/automationFixtures';
import { AUTOMATIONS } from 'dashboard/routes/dashboard/settings/automation/constants';
describe('getCustomAttributeInputType', () => {
it('returns the attribute input type', () => {
expect(helpers.getCustomAttributeInputType('date')).toEqual('date');
expect(helpers.getCustomAttributeInputType('date')).not.toEqual(
'some_random_value'
);
expect(helpers.getCustomAttributeInputType('text')).toEqual('plain_text');
expect(helpers.getCustomAttributeInputType('list')).toEqual(
'search_select'
);
expect(helpers.getCustomAttributeInputType('checkbox')).toEqual(
'search_select'
);
expect(helpers.getCustomAttributeInputType('some_random_text')).toEqual(
'plain_text'
);
});
});
describe('isACustomAttribute', () => {
it('returns the custom attribute value if true', () => {
expect(
helpers.isACustomAttribute(customAttributes, 'signed_up_at')
).toBeTruthy();
expect(helpers.isACustomAttribute(customAttributes, 'status')).toBeFalsy();
});
});
describe('getCustomAttributeListDropdownValues', () => {
it('returns the attribute dropdown values', () => {
const myListValues = [
{ id: 'item1', name: 'item1' },
{ id: 'item2', name: 'item2' },
{ id: 'item3', name: 'item3' },
];
expect(
helpers.getCustomAttributeListDropdownValues(customAttributes, 'my_list')
).toEqual(myListValues);
});
});
describe('isCustomAttributeCheckbox', () => {
it('checks if attribute is a checkbox', () => {
expect(
helpers.isCustomAttributeCheckbox(customAttributes, 'prime_user')
.attribute_display_type
).toEqual('checkbox');
expect(
helpers.isCustomAttributeCheckbox(customAttributes, 'my_check')
.attribute_display_type
).toEqual('checkbox');
expect(
helpers.isCustomAttributeCheckbox(customAttributes, 'my_list')
).not.toEqual('checkbox');
});
});
describe('isCustomAttributeList', () => {
it('checks if attribute is a list', () => {
expect(
helpers.isCustomAttributeList(customAttributes, 'my_list')
.attribute_display_type
).toEqual('list');
});
});
describe('getOperatorTypes', () => {
it('returns the correct custom attribute operators', () => {
expect(helpers.getOperatorTypes('list')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('text')).toEqual(OPERATOR_TYPES_3);
expect(helpers.getOperatorTypes('number')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('link')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('date')).toEqual(OPERATOR_TYPES_4);
expect(helpers.getOperatorTypes('checkbox')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('some_random')).toEqual(OPERATOR_TYPES_1);
});
});
describe('generateConditionOptions', () => {
it('returns expected conditions options array', () => {
const testConditions = [
{ id: 123, title: 'Fayaz', email: 'test@test.com' },
{ title: 'John', id: 324, email: 'test@john.com' },
];
const expectedConditions = [
{ id: 123, name: 'Fayaz' },
{ id: 324, name: 'John' },
];
expect(helpers.generateConditionOptions(testConditions)).toEqual(
expectedConditions
);
});
});
describe('getActionOptions', () => {
it('returns expected actions options array', () => {
const expectedOptions = [
{ id: 'testlabel', name: 'testlabel' },
{ id: 'snoozes', name: 'snoozes' },
];
expect(helpers.getActionOptions({ labels, type: 'add_label' })).toEqual(
expectedOptions
);
});
it('adds None option when addNoneToListFn is provided', () => {
const mockAddNoneToListFn = list => [
{ id: 'nil', name: 'None' },
...(list || []),
];
const agents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
const expectedOptions = [
{ id: 'nil', name: 'None' },
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
expect(
helpers.getActionOptions({
agents,
type: 'assign_agent',
addNoneToListFn: mockAddNoneToListFn,
})
).toEqual(expectedOptions);
});
it('does not add None option when addNoneToListFn is not provided', () => {
const agents = [
{ id: 1, name: 'Agent 1' },
{ id: 2, name: 'Agent 2' },
];
expect(
helpers.getActionOptions({
agents,
type: 'assign_agent',
})
).toEqual(agents);
});
});
describe('getConditionOptions', () => {
it('returns expected conditions options', () => {
const testOptions = [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
];
expect(
helpers.getConditionOptions({
customAttributes,
campaigns: [],
statusFilterOptions: testOptions,
type: 'status',
})
).toEqual(testOptions);
});
});
describe('getFileName', () => {
it('returns the correct file name', () => {
expect(
helpers.getFileName(automation.actions[0], automation.files)
).toEqual('pfp.jpeg');
});
});
describe('getDefaultConditions', () => {
it('returns the resp default condition model', () => {
const messageCreatedModel = [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
const genericConditionModel = [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
expect(helpers.getDefaultConditions('message_created')).toEqual(
messageCreatedModel
);
expect(helpers.getDefaultConditions()).toEqual(genericConditionModel);
});
});
describe('getDefaultActions', () => {
it('returns the resp default action model', () => {
const genericActionModel = [
{
action_name: 'assign_agent',
action_params: [],
},
];
expect(helpers.getDefaultActions()).toEqual(genericActionModel);
});
});
describe('filterCustomAttributes', () => {
it('filters the raw custom attributes', () => {
const filteredAttributes = [
{ key: 'signed_up_at', name: 'Signed Up At', type: 'date' },
{ key: 'prime_user', name: 'Prime User', type: 'checkbox' },
{ key: 'test', name: 'Test', type: 'text' },
{ key: 'link', name: 'Link', type: 'link' },
{ key: 'my_list', name: 'My List', type: 'list' },
{ key: 'my_check', name: 'My Check', type: 'checkbox' },
{ key: 'conlist', name: 'ConList', type: 'list' },
{ key: 'asdf', name: 'asdf', type: 'link' },
];
expect(helpers.filterCustomAttributes(customAttributes)).toEqual(
filteredAttributes
);
});
});
describe('getStandardAttributeInputType', () => {
it('returns the resp default action model', () => {
expect(
helpers.getStandardAttributeInputType(
AUTOMATIONS,
'message_created',
'message_type'
)
).toEqual('search_select');
expect(
helpers.getStandardAttributeInputType(
AUTOMATIONS,
'conversation_created',
'status'
)
).toEqual('multi_select');
expect(
helpers.getStandardAttributeInputType(
AUTOMATIONS,
'conversation_updated',
'referer'
)
).toEqual('plain_text');
});
});
describe('generateAutomationPayload', () => {
it('returns the resp default action model', () => {
const testPayload = {
name: 'Test',
description: 'This is a test',
event_name: 'conversation_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: [{ id: 'open', name: 'Open' }],
query_operator: 'and',
},
],
actions: [
{
action_name: 'add_label',
action_params: [{ id: 2, name: 'testlabel' }],
},
],
};
const expectedPayload = {
name: 'Test',
description: 'This is a test',
event_name: 'conversation_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
},
],
actions: [
{
action_name: 'add_label',
action_params: [2],
},
],
};
expect(helpers.generateAutomationPayload(testPayload)).toEqual(
expectedPayload
);
});
});
describe('isCustomAttribute', () => {
it('returns the resp default action model', () => {
const attrs = helpers.filterCustomAttributes(customAttributes);
expect(helpers.isCustomAttribute(attrs, 'my_list')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'my_check')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'signed_up_at')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'link')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'prime_user')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'hello')).toBeFalsy();
});
});
describe('generateCustomAttributes', () => {
it('generates and returns correct condition attribute', () => {
expect(
helpers.generateCustomAttributes(
conversationAttrs,
contactAttrs,
'Conversation Custom Attributes',
'Contact Custom Attributes'
)
).toEqual(expectedOutputForCustomAttributeGenerator);
});
});
describe('getAttributes', () => {
it('returns the conditions for the given automation type', () => {
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
});
});
describe('getAttributes', () => {
it('returns the conditions for the given automation type', () => {
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
});
});
describe('getAutomationType', () => {
it('returns the automation type for the given key', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getAutomationType(
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
);
});
});
describe('getInputType', () => {
it('returns the input type for a custom attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getInputType(
customAttributes,
AUTOMATIONS,
mockAutomation,
'signed_up_at'
);
expect(result).toEqual('date');
});
it('returns the input type for a standard attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getInputType(
customAttributes,
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual('search_select');
});
});
describe('getOperators', () => {
it('returns operators for a custom attribute in edit mode', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getOperators(
customAttributes,
AUTOMATIONS,
mockAutomation,
'edit',
'signed_up_at'
);
expect(result).toEqual(OPERATOR_TYPES_4);
});
it('returns operators for a standard attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getOperators(
customAttributes,
AUTOMATIONS,
mockAutomation,
'create',
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
.filterOperators
);
});
});
describe('getCustomAttributeType', () => {
it('returns the custom attribute type for the given key', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getCustomAttributeType(
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
.customAttributeType
);
});
});
describe('showActionInput', () => {
it('returns false for send_email_to_team and send_message actions', () => {
expect(helpers.showActionInput([], 'send_email_to_team')).toBe(false);
expect(helpers.showActionInput([], 'send_message')).toBe(false);
});
it('returns true if the action has an input type', () => {
const mockActionTypes = [{ key: 'add_label', inputType: 'select' }];
expect(helpers.showActionInput(mockActionTypes, 'add_label')).toBe(true);
});
it('returns false if the action does not have an input type', () => {
const mockActionTypes = [{ key: 'some_action', inputType: null }];
expect(helpers.showActionInput(mockActionTypes, 'some_action')).toBe(false);
});
});

View File

@@ -0,0 +1,193 @@
import {
getTypingUsersText,
createPendingMessage,
convertToAttributeSlug,
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
formatToTitleCase,
} from '../commons';
describe('#getTypingUsersText', () => {
it('returns the correct text is there is only one typing user', () => {
expect(getTypingUsersText([{ name: 'Pranav' }])).toEqual([
'TYPING.ONE',
{ user: 'Pranav' },
]);
});
it('returns the correct text is there are two typing users', () => {
expect(
getTypingUsersText([{ name: 'Pranav' }, { name: 'Nithin' }])
).toEqual(['TYPING.TWO', { user: 'Pranav', secondUser: 'Nithin' }]);
});
it('returns the correct text is there are more than two users are typing', () => {
expect(
getTypingUsersText([
{ name: 'Pranav' },
{ name: 'Nithin' },
{ name: 'Subin' },
{ name: 'Sojan' },
])
).toEqual(['TYPING.MULTIPLE', { user: 'Pranav', count: 3 }]);
});
});
describe('#createPendingMessage', () => {
const message = {
message: 'hi',
};
it('returns the pending message with expected new keys', () => {
expect(createPendingMessage(message)).toMatchObject({
content: expect.anything(),
id: expect.anything(),
status: expect.anything(),
echo_id: expect.anything(),
created_at: expect.anything(),
message_type: expect.anything(),
});
});
it('returns the pending message with status progress', () => {
expect(createPendingMessage(message)).toMatchObject({
status: 'progress',
});
});
it('returns the pending message with same id and echo_id', () => {
const pending = createPendingMessage(message);
expect(pending).toMatchObject({
echo_id: pending.id,
});
});
it('returns the pending message with attachment key if file is passed', () => {
const messageWithFile = {
message: 'hi',
file: {},
};
expect(createPendingMessage(messageWithFile)).toMatchObject({
content: expect.anything(),
id: expect.anything(),
status: expect.anything(),
echo_id: expect.anything(),
created_at: expect.anything(),
message_type: expect.anything(),
attachments: [{ id: expect.anything() }],
});
});
it('returns the pending message to have one attachment', () => {
const messageWithFile = {
message: 'hi',
file: {},
};
const pending = createPendingMessage(messageWithFile);
expect(pending.attachments.length).toBe(1);
});
});
describe('convertToAttributeSlug', () => {
it('should convert to slug', () => {
expect(convertToAttributeSlug('Test@%^&*(){}>.!@`~_ ing')).toBe(
'test__ing'
);
});
});
describe('convertToCategorySlug', () => {
it('should convert to slug', () => {
expect(convertToCategorySlug('User profile guide')).toBe(
'user-profile-guide'
);
});
});
describe('convertToPortalSlug', () => {
it('should convert to slug', () => {
expect(convertToPortalSlug('Room rental')).toBe('room-rental');
});
});
describe('sanitizeVariableSearchKey', () => {
it('removes braces', () => {
expect(sanitizeVariableSearchKey('{{contact.name}}')).toBe('contact.name');
});
it('removes right braces', () => {
expect(sanitizeVariableSearchKey('contact.name}}')).toBe('contact.name');
});
it('removes braces, comma and whitespace', () => {
expect(sanitizeVariableSearchKey(' {{contact.name }},')).toBe(
'contact.name'
);
});
it('trims whitespace', () => {
expect(sanitizeVariableSearchKey(' contact.name ')).toBe('contact.name');
});
it('handles multiple commas', () => {
expect(sanitizeVariableSearchKey('{{contact.name}},,')).toBe(
'contact.name'
);
});
it('returns empty string when only braces/commas/whitespace', () => {
expect(sanitizeVariableSearchKey(' { }, , ')).toBe('');
});
it('returns empty string for undefined input', () => {
expect(sanitizeVariableSearchKey()).toBe('');
});
});
describe('formatToTitleCase', () => {
it('converts underscore-separated string to title case', () => {
expect(formatToTitleCase('round_robin')).toBe('Round Robin');
});
it('converts single word to title case', () => {
expect(formatToTitleCase('priority')).toBe('Priority');
});
it('converts multiple underscores to title case', () => {
expect(formatToTitleCase('auto_assignment_policy')).toBe(
'Auto Assignment Policy'
);
});
it('handles already capitalized words', () => {
expect(formatToTitleCase('HIGH_PRIORITY')).toBe('HIGH PRIORITY');
});
it('handles mixed case with underscores', () => {
expect(formatToTitleCase('first_Name_last')).toBe('First Name Last');
});
it('handles empty string', () => {
expect(formatToTitleCase('')).toBe('');
});
it('handles null input', () => {
expect(formatToTitleCase(null)).toBe('');
});
it('handles undefined input', () => {
expect(formatToTitleCase(undefined)).toBe('');
});
it('handles string without underscores', () => {
expect(formatToTitleCase('hello')).toBe('Hello');
});
it('handles string with numbers', () => {
expect(formatToTitleCase('priority_1_high')).toBe('Priority 1 High');
});
it('handles leading and trailing underscores', () => {
expect(formatToTitleCase('_leading_trailing_')).toBe('Leading Trailing');
});
});

View File

@@ -0,0 +1,101 @@
import {
filterDuplicateSourceMessages,
getLastMessage,
getReadMessages,
getUnreadMessages,
} from '../conversationHelper';
import {
conversationData,
lastMessageData,
readMessagesData,
unReadMessagesData,
} from './fixtures/conversationFixtures';
describe('conversationHelper', () => {
describe('#filterDuplicateSourceMessages', () => {
it('returns messages without duplicate source_id and all messages without source_id', () => {
const input = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_1', id: 5 },
{ source_id: 'wa_1', id: 6 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_2', id: 8 },
{ source_id: 'wa_3', id: 9 },
];
const expected = [
{ source_id: null, id: 1 },
{ source_id: '', id: 2 },
{ id: 3 },
{ source_id: 'wa_1', id: 4 },
{ source_id: 'wa_2', id: 7 },
{ source_id: 'wa_3', id: 9 },
];
expect(filterDuplicateSourceMessages(input)).toEqual(expected);
});
});
describe('#readMessages', () => {
it('should return read messages if conversation is passed', () => {
expect(
getReadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(readMessagesData);
});
});
describe('#unReadMessages', () => {
it('should return unread messages if conversation is passed', () => {
expect(
getUnreadMessages(
conversationData.messages,
conversationData.agent_last_seen_at
)
).toEqual(unReadMessagesData);
});
});
describe('#lastMessage', () => {
it("should return last activity message if both api and store doesn't have other messages", () => {
const testConversation = {
messages: [conversationData.messages[0]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[0]
);
});
it('should return message from store if store has latest message', () => {
const testConversation = {
messages: [],
last_non_activity_message: lastMessageData,
};
expect(getLastMessage(testConversation)).toEqual(lastMessageData);
});
it('should return last non activity message from store if api value is empty', () => {
const testConversation = {
messages: [conversationData.messages[0], conversationData.messages[1]],
last_non_activity_message: null,
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
it("should return last non activity message from store if store doesn't have any messages", () => {
const testConversation = {
messages: [conversationData.messages[1], conversationData.messages[2]],
last_non_activity_message: conversationData.messages[0],
};
expect(getLastMessage(testConversation)).toEqual(
testConversation.messages[1]
);
});
});
});

View File

@@ -0,0 +1,305 @@
import {
getAttributeInputType,
getInputType,
getValuesName,
getValuesForStatus,
getValuesForFilter,
generateValuesForEditCustomViews,
generateCustomAttributesInputType,
} from '../customViewsHelper';
import advancedFilterTypes from 'dashboard/components/widgets/conversation/advancedFilterItems/index';
describe('customViewsHelper', () => {
describe('#getInputType', () => {
it('should return plain_text if key is created_at or last_activity_at and operator is days_before', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
];
expect(getInputType('created_at', 'days_before', filterTypes)).toEqual(
'plain_text'
);
expect(
getInputType('last_activity_at', 'days_before', filterTypes)
).toEqual('plain_text');
});
it('should return inputType if key is not created_at or last_activity_at', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
it('should return undefined if key is not created_at or last_activity_at and inputType is not present', () => {
const filterTypes = [
{ attributeKey: 'created_at', inputType: 'date' },
{ attributeKey: 'last_activity_at', inputType: 'date' },
{ attributeKey: 'test', inputType: 'string' },
];
expect(getInputType('test', 'days_before', filterTypes)).toEqual(
'string'
);
});
});
describe('#getAttributeInputType', () => {
it('should return multi_select if attribute_display_type is checkbox or list', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'multi_select'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'multi_select'
);
});
it('should return string if attribute_display_type is text, number, date or link', () => {
const allCustomAttributes = [
{ attribute_key: 'test', attribute_display_type: 'text' },
{ attribute_key: 'test2', attribute_display_type: 'number' },
{ attribute_key: 'test3', attribute_display_type: 'date' },
{ attribute_key: 'test4', attribute_display_type: 'link' },
];
expect(getAttributeInputType('test', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test2', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test3', allCustomAttributes)).toEqual(
'string'
);
expect(getAttributeInputType('test4', allCustomAttributes)).toEqual(
'string'
);
});
});
describe('#getValuesName', () => {
it('should return id and name if item is present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [1];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and value if item is not present', () => {
const list = [{ id: 1, name: 'test' }];
const idKey = 'id';
const nameKey = 'name';
const values = [2];
expect(getValuesName(values, list, idKey, nameKey)).toEqual({
id: 2,
name: 2,
});
});
});
describe('#getValuesForStatus', () => {
it('should return id and name if value is present', () => {
const values = ['open'];
expect(getValuesForStatus(values)).toEqual([
{ id: 'open', name: 'open' },
]);
});
it('should return id and name if multiple values are present', () => {
const values = ['open', 'resolved'];
expect(getValuesForStatus(values)).toEqual([
{ id: 'open', name: 'open' },
{ id: 'resolved', name: 'resolved' },
]);
});
});
describe('#getValuesForFilter', () => {
it('should return id and name if attribute_key is status', () => {
const filter = { attribute_key: 'status', values: ['open', 'resolved'] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'open', name: 'open' },
{ id: 'resolved', name: 'resolved' },
]);
});
it('should return id and name if attribute_key is assignee_id', () => {
const filter = { attribute_key: 'assignee_id', values: [1] };
const params = { agents: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is inbox_id', () => {
const filter = { attribute_key: 'inbox_id', values: [1] };
const params = { inboxes: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if attribute_key is team_id', () => {
const filter = { attribute_key: 'team_id', values: [1] };
const params = { teams: [{ id: 1, name: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is campaign_id', () => {
const filter = { attribute_key: 'campaign_id', values: [1] };
const params = { campaigns: [{ id: 1, title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and title if attribute_key is labels', () => {
const filter = { attribute_key: 'labels', values: ['test'] };
const params = { labels: [{ title: 'test' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'test', name: 'test' },
]);
});
it('should return id and name if attribute_key is browser_language', () => {
const filter = { attribute_key: 'browser_language', values: ['en'] };
const params = { languages: [{ id: 'en', name: 'English' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'en', name: 'English' },
]);
});
it('should return id and name if attribute_key is country_code', () => {
const filter = { attribute_key: 'country_code', values: ['IN'] };
const params = { countries: [{ id: 'IN', name: 'India' }] };
expect(getValuesForFilter(filter, params)).toEqual([
{ id: 'IN', name: 'India' },
]);
});
it('should return id and name if attribute_key is not present', () => {
const filter = { attribute_key: 'test', values: [1] };
const params = {};
expect(getValuesForFilter(filter, params)).toEqual({
id: 1,
name: 1,
});
});
});
describe('#generateValuesForEditCustomViews', () => {
it('should return id and name if inboxType is multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is not multi_select or search_select', () => {
const filter = {
attribute_key: 'assignee_id',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [],
agents: [{ id: 1, name: 'test' }],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 'test',
});
});
it('should return id and name if inboxType is undefined', () => {
const filter = {
attribute_key: 'test2',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'checkbox' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual({
id: 1,
name: 1,
});
});
it('should return value as string if filterInputTypes is string', () => {
const filter = {
attribute_key: 'test',
filter_operator: 'and',
values: [1],
};
const params = {
filterTypes: advancedFilterTypes,
allCustomAttributes: [
{ attribute_key: 'test', attribute_display_type: 'date' },
{ attribute_key: 'test2', attribute_display_type: 'list' },
],
};
expect(generateValuesForEditCustomViews(filter, params)).toEqual('1');
});
});
describe('#generateCustomAttributesInputType', () => {
it('should return string if type is text', () => {
expect(generateCustomAttributesInputType('text')).toEqual('string');
});
it('should return string if type is number', () => {
expect(generateCustomAttributesInputType('number')).toEqual('string');
});
it('should return string if type is date', () => {
expect(generateCustomAttributesInputType('date')).toEqual('string');
});
it('should return multi_select if type is checkbox', () => {
expect(generateCustomAttributesInputType('checkbox')).toEqual(
'multi_select'
);
});
it('should return multi_select if type is list', () => {
expect(generateCustomAttributesInputType('list')).toEqual('multi_select');
});
it('should return string if type is link', () => {
expect(generateCustomAttributesInputType('link')).toEqual('string');
});
});
});

View File

@@ -0,0 +1,13 @@
import { generateFileName } from '../downloadHelper';
describe('#generateFileName', () => {
it('should generate the correct file name', () => {
expect(generateFileName({ type: 'csat', to: 1652812199 })).toEqual(
'csat-report-17-05-2022.csv'
);
expect(
generateFileName({ type: 'csat', to: 1652812199, businessHours: true })
).toEqual('csat-report-17-05-2022-business-hours.csv');
});
});

View File

@@ -0,0 +1,137 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import { MessageMarkdownTransformer } from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
}));
vi.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: vi.fn(),
}));
describe('getContentNode', () => {
let editorView;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(),
},
},
text: vi.fn(),
},
},
};
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
mentionType: 'user',
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
// Mock the node that will be returned by parse
const mockNode = { textContent: updatedMessage };
replaceVariablesInMessage.mockReturnValue(updatedMessage);
// Mock MessageMarkdownTransformer instance with parse method
const mockTransformer = {
parse: vi.fn().mockReturnValue(mockNode),
};
MessageMarkdownTransformer.mockImplementation(() => mockTransformer);
const result = getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(
editorView.state.schema
);
expect(mockTransformer.parse).toHaveBeenCalledWith(updatedMessage);
expect(result.node).toBe(mockNode);
expect(result.node.textContent).toBe(updatedMessage);
// When textContent matches updatedMessage, from should remain unchanged
expect(result.from).toBe(from);
expect(result.to).toBe(to);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getEmojiNode', () => {
it('should create an emoji node', () => {
const content = '😊';
const from = 0;
const to = 2;
getContentNode(editorView, 'emoji', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = getContentNode(editorView, 'invalid', content, {
from,
to,
});
expect(node).toBeNull();
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = `
<p>method</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Hi</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Yes, it is.</p>
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot &lt; shaneforwoot@gmail.com&gt; wrote:</p>
<blockquote>
<p>Hey</p>
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
<p>This is another quoted quoted text reply</p>
<p>This is nice</p>
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot &lt; &gt; shaneforwoot@gmail.com&gt; wrote:</p>
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
<p>Hey there</p>
<p>This is an email text, enjoy reading this</p>
<p>-- Shivam Mishra, Chatwoot</p>
</blockquote>
</blockquote>
</blockquote>
`;
const EMAIL_WITH_SIGNATURE = `
<p>Latest reply here.</p>
<p>Thanks,</p>
<p>Jane Doe</p>
<blockquote>
<p>On Mon, Sep 22, Someone wrote:</p>
<p>Previous reply content</p>
</blockquote>
`;
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<blockquote>
<p>Inline quote that should stay</p>
</blockquote>
<p>Internal note follows</p>
<p>Regards,</p>
`;
describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
});
it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
EMAIL_WITH_FOLLOW_UP_CONTENT
);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
});
it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
expect(result).toBe(true);
});
it('does not flag blockquotes that are followed by other elements', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe(
false
);
});
it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>';
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
expect(cleanedHtml).toContain('<p>Thanks,</p>');
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
expect(cleanedHtml).not.toContain('<blockquote');
});
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');
});
});
});

View File

@@ -0,0 +1,67 @@
import filterQueryGenerator from '../filterQueryGenerator';
const testData = [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: [
{ id: 'pending', name: 'Pending' },
{ id: 'resolved', name: 'Resolved' },
],
query_operator: 'and',
},
{
attribute_key: 'assignee',
filter_operator: 'equal_to',
values: {
id: 3,
account_id: 1,
auto_offline: true,
confirmed: true,
email: 'fayaz@test.com',
available_name: 'Fayaz',
name: 'Fayaz',
role: 'agent',
thumbnail:
'https://www.gravatar.com/avatar/a35bf18a632f734c8d0c883dcc9fa0ef?d=404',
},
query_operator: 'and',
},
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: 'This is a test',
query_operator: 'or',
},
];
const finalResult = {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending', 'resolved'],
query_operator: 'and',
},
{
attribute_key: 'assignee',
filter_operator: 'equal_to',
values: [3],
query_operator: 'and',
},
{
attribute_key: 'id',
filter_operator: 'equal_to',
values: ['This is a test'],
},
],
};
describe('#filterQueryGenerator', () => {
it('returns the correct format of filter query', () => {
expect(filterQueryGenerator(testData)).toMatchObject(finalResult);
expect(
filterQueryGenerator(testData).payload.every(i => Array.isArray(i.values))
).toBe(true);
});
});

View File

@@ -0,0 +1,184 @@
export const allAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
];
export const onlineAgentsData = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
];
export const busyAgentsData = [
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
];
export const offlineAgentsData = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const sortedByAvailability = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];
export const formattedAgentsByPresenceOnline = [
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];
export const formattedAgentsByPresenceOffline = [
{
account_id: 1,
availability_status: 'offline',
available_name: 'Abraham',
confirmed: true,
email: 'abr@chatwoot.com',
id: 1,
name: 'Abraham Keta',
role: 'agent',
},
];

View File

@@ -0,0 +1,811 @@
import allLanguages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import allCountries from 'shared/constants/countries.js';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
export const customAttributes = [
{
id: 1,
attribute_display_name: 'Signed Up At',
attribute_display_type: 'date',
attribute_description: 'This is a test',
attribute_key: 'signed_up_at',
attribute_values: [],
attribute_model: 'conversation_attribute',
default_value: null,
created_at: '2022-01-26T08:06:39.470Z',
updated_at: '2022-01-26T08:06:39.470Z',
},
{
id: 2,
attribute_display_name: 'Prime User',
attribute_display_type: 'checkbox',
attribute_description: 'Test',
attribute_key: 'prime_user',
attribute_values: [],
attribute_model: 'contact_attribute',
default_value: null,
created_at: '2022-01-26T08:07:29.664Z',
updated_at: '2022-01-26T08:07:29.664Z',
},
{
id: 3,
attribute_display_name: 'Test',
attribute_display_type: 'text',
attribute_description: 'Test',
attribute_key: 'test',
attribute_values: [],
attribute_model: 'conversation_attribute',
default_value: null,
created_at: '2022-01-26T08:07:58.325Z',
updated_at: '2022-01-26T08:07:58.325Z',
},
{
id: 4,
attribute_display_name: 'Link',
attribute_display_type: 'link',
attribute_description: 'Test',
attribute_key: 'link',
attribute_values: [],
attribute_model: 'conversation_attribute',
default_value: null,
created_at: '2022-02-07T07:31:51.562Z',
updated_at: '2022-02-07T07:31:51.562Z',
},
{
id: 5,
attribute_display_name: 'My List',
attribute_display_type: 'list',
attribute_description: 'This is a sample list',
attribute_key: 'my_list',
attribute_values: ['item1', 'item2', 'item3'],
attribute_model: 'conversation_attribute',
default_value: null,
created_at: '2022-02-21T20:31:34.175Z',
updated_at: '2022-02-21T20:31:34.175Z',
},
{
id: 6,
attribute_display_name: 'My Check',
attribute_display_type: 'checkbox',
attribute_description: 'Test Checkbox',
attribute_key: 'my_check',
attribute_values: [],
attribute_model: 'conversation_attribute',
default_value: null,
created_at: '2022-02-21T20:31:53.385Z',
updated_at: '2022-02-21T20:31:53.385Z',
},
{
id: 7,
attribute_display_name: 'ConList',
attribute_display_type: 'list',
attribute_description: 'This is a test list\n',
attribute_key: 'conlist',
attribute_values: ['Hello', 'Test', 'Test2'],
attribute_model: 'contact_attribute',
default_value: null,
created_at: '2022-02-28T12:58:05.005Z',
updated_at: '2022-02-28T12:58:05.005Z',
},
{
id: 8,
attribute_display_name: 'asdf',
attribute_display_type: 'link',
attribute_description: 'This is a some text',
attribute_key: 'asdf',
attribute_values: [],
attribute_model: 'contact_attribute',
default_value: null,
created_at: '2022-04-21T05:48:16.168Z',
updated_at: '2022-04-21T05:48:16.168Z',
},
];
export const emptyAutomation = {
name: null,
description: null,
event_name: 'conversation_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
},
],
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
};
export const filterAttributes = [
{
key: 'status',
name: 'Status',
attributeI18nKey: 'STATUS',
inputType: 'multi_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'browser_language',
name: 'Browser Language',
attributeI18nKey: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'country_code',
name: 'Country',
attributeI18nKey: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'referer',
name: 'Referrer Link',
attributeI18nKey: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
{ value: 'contains', label: 'Contains' },
{ value: 'does_not_contain', label: 'Does not contain' },
],
},
{
key: 'inbox_id',
name: 'Inbox',
attributeI18nKey: 'INBOX',
inputType: 'multi_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'conversation_custom_attribute',
name: 'Conversation Custom Attributes',
disabled: true,
},
{
key: 'signed_up_at',
name: 'Signed Up At',
inputType: 'date',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
{ value: 'is_present', label: 'Is present' },
{ value: 'is_not_present', label: 'Is not present' },
{ value: 'is_greater_than', label: 'Is greater than' },
{ value: 'is_less_than', label: 'Is less than' },
],
},
{
key: 'test',
name: 'Test',
inputType: 'plain_text',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
{ value: 'is_present', label: 'Is present' },
{ value: 'is_not_present', label: 'Is not present' },
],
},
{
key: 'link',
name: 'Link',
inputType: 'plain_text',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'my_list',
name: 'My List',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'my_check',
name: 'My Check',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'contact_custom_attribute',
name: 'Contact Custom Attributes',
disabled: true,
},
{
key: 'prime_user',
name: 'Prime User',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'conlist',
name: 'ConList',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
{
key: 'asdf',
name: 'asdf',
inputType: 'plain_text',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
];
export const automation = {
id: 164,
account_id: 1,
name: 'Attachment',
description: 'Yo',
event_name: 'conversation_created',
conditions: [
{
values: [{ id: 'open', name: 'Open' }],
attribute_key: 'status',
filter_operator: 'equal_to',
query_operator: 'and',
},
],
actions: [{ action_name: 'send_attachment', action_params: [59] }],
created_on: 1652717181,
active: true,
files: [
{
id: 50,
automation_rule_id: 164,
file_type: 'image/jpeg',
account_id: 1,
file_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBRQT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--965b4c27f4c5e47c526f0f38266b25417b72e5dd/pfp.jpeg',
blob_id: 59,
filename: 'pfp.jpeg',
},
],
};
export const agents = [
{
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@acme.inc',
available_name: 'Fayaz',
name: 'Fayaz',
role: 'administrator',
thumbnail:
'https://www.gravatar.com/avatar/0d722ac7bc3b3c92c030d0da9690d981?d=404',
},
{
id: 5,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John',
name: 'John',
role: 'agent',
thumbnail:
'https://www.gravatar.com/avatar/6a6c19fea4a3676970167ce51f39e6ee?d=404',
},
];
export const booleanFilterOptions = [
{
id: true,
name: 'True',
},
{
id: false,
name: 'False',
},
];
export const teams = [
{
id: 1,
name: 'sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: 'fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: false,
},
];
export const campaigns = [];
export const contacts = [
{
additional_attributes: {},
availability_status: 'offline',
email: 'asd123123@asd.com',
id: 32,
name: 'asd123123',
phone_number: null,
identifier: null,
thumbnail:
'https://www.gravatar.com/avatar/46000d9a1eef3e24a02ca9d6c2a8f494?d=404',
custom_attributes: {},
conversations_count: 5,
last_activity_at: 1650519706,
},
{
additional_attributes: {},
availability_status: 'offline',
email: 'barry_allen@a.com',
id: 29,
name: 'barry_allen',
phone_number: null,
identifier: null,
thumbnail:
'https://www.gravatar.com/avatar/ab5ff99efa3bc1f74db1dc2885f9e2ce?d=404',
custom_attributes: {},
conversations_count: 1,
last_activity_at: 1643728899,
},
];
export const inboxes = [
{
id: 1,
avatar_url: '',
channel_id: 1,
name: 'Acme Support',
channel_type: 'Channel::WebWidget',
greeting_enabled: false,
greeting_message: '',
working_hours_enabled: false,
enable_email_collect: true,
csat_survey_enabled: true,
sender_name_type: 0,
enable_auto_assignment: true,
out_of_office_message:
'We are unavailable at the moment. Leave a message we will respond once we are back.',
working_hours: [
{
day_of_week: 0,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
open_all_day: false,
},
{
day_of_week: 1,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 2,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 3,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 5,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 6,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
open_all_day: false,
},
],
timezone: 'America/Los_Angeles',
callback_webhook_url: null,
allow_messages_after_resolved: true,
widget_color: '#1f93ff',
website_url: 'https://acme.inc',
hmac_mandatory: false,
welcome_title: '',
welcome_tagline: '',
web_widget_script:
'\n <script>\n (function(d,t) {\n var BASE_URL="http://localhost:3000";\n var g=d.createElement(t),s=d.getElementsByTagName(t)[0];\n g.src=BASE_URL+"/packs/js/sdk.js";\n g.async = true;\n s.parentNode.insertBefore(g,s);\n g.onload=function(){\n window.chatwootSDK.run({\n websiteToken: \'yZ7USzaEs7hrwUAHLGwjbxJ1\',\n baseUrl: BASE_URL\n })\n }\n })(document,"script");\n </script>\n ',
website_token: 'yZ7USzaEs7hrwUAHLGwjbxJ1',
selected_feature_flags: ['attachments', 'emoji_picker', 'end_conversation'],
reply_time: 'in_a_few_minutes',
hmac_token: 'rRJW1BHu4aFMMey4SE7tWr8A',
pre_chat_form_enabled: false,
pre_chat_form_options: {
pre_chat_fields: [
{
name: 'emailAddress',
type: 'email',
label: 'Email Id',
enabled: false,
required: true,
field_type: 'standard',
},
{
name: 'fullName',
type: 'text',
label: 'Full name',
enabled: false,
required: false,
field_type: 'standard',
},
{
name: 'phoneNumber',
type: 'text',
label: 'Phone number',
enabled: false,
required: false,
field_type: 'standard',
},
],
pre_chat_message: 'Share your queries or comments here.',
},
continuity_via_email: true,
phone_number: null,
},
{
id: 2,
avatar_url: '',
channel_id: 1,
name: 'Email',
channel_type: 'Channel::Email',
greeting_enabled: false,
greeting_message: null,
working_hours_enabled: false,
enable_email_collect: true,
csat_survey_enabled: false,
enable_auto_assignment: true,
out_of_office_message: null,
working_hours: [
{
day_of_week: 0,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
open_all_day: false,
},
{
day_of_week: 1,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 2,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 3,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 5,
closed_all_day: false,
open_hour: 9,
open_minutes: 0,
close_hour: 17,
close_minutes: 0,
open_all_day: false,
},
{
day_of_week: 6,
closed_all_day: true,
open_hour: null,
open_minutes: null,
close_hour: null,
close_minutes: null,
open_all_day: false,
},
],
timezone: 'UTC',
callback_webhook_url: null,
allow_messages_after_resolved: true,
widget_color: null,
website_url: null,
hmac_mandatory: null,
welcome_title: null,
welcome_tagline: null,
web_widget_script: null,
website_token: null,
selected_feature_flags: null,
reply_time: null,
phone_number: null,
forward_to_email: '9ae8ebb96c7f2d6705009f5add6d1a2d@false',
email: 'fayaz@chatwoot.com',
imap_login: '',
imap_password: '',
imap_address: '',
imap_port: 0,
imap_enabled: false,
imap_enable_ssl: true,
smtp_login: '',
smtp_password: '',
smtp_address: '',
smtp_port: 0,
smtp_enabled: false,
smtp_domain: '',
smtp_enable_ssl_tls: false,
smtp_enable_starttls_auto: true,
smtp_openssl_verify_mode: 'none',
smtp_authentication: 'login',
},
];
export const labels = [
{
id: 2,
title: 'testlabel',
},
{
id: 1,
title: 'snoozes',
},
];
export const statusFilterOptions = [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
];
export const languages = allLanguages;
export const countries = allCountries;
export const messageTypeOptions = MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`,
}));
export const priorityOptions = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`,
}));
export const automationToSubmit = {
name: 'Fayaz',
description: 'Hello',
event_name: 'conversation_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: [{ id: 'open', name: 'Open' }],
query_operator: 'and',
custom_attribute_type: '',
},
],
actions: [
{ action_name: 'add_label', action_params: [{ id: 2, name: 'testlabel' }] },
],
};
export const savedAutomation = {
id: 165,
account_id: 1,
name: 'Fayaz',
description: 'Hello',
event_name: 'conversation_created',
conditions: [
{
values: ['open'],
attribute_key: 'status',
filter_operator: 'equal_to',
},
],
actions: [
{
action_name: 'add_label',
action_params: [2],
},
],
created_on: 1652776043,
active: true,
};
export const contactAttrs = [
{
key: 'contact_list',
name: 'Contact List',
inputType: 'search_select',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
],
},
];
export const conversationAttrs = [
{
key: 'text_attr',
name: 'Text Attr',
inputType: 'plain_text',
filterOperators: [
{
value: 'equal_to',
label: 'Equal to',
},
{
value: 'not_equal_to',
label: 'Not equal to',
},
{
value: 'is_present',
label: 'Is present',
},
{
value: 'is_not_present',
label: 'Is not present',
},
],
},
];
export const expectedOutputForCustomAttributeGenerator = [
{
key: 'conversation_custom_attribute',
name: 'Conversation Custom Attributes',
disabled: true,
},
{
key: 'text_attr',
name: 'Text Attr',
inputType: 'plain_text',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
{ value: 'is_present', label: 'Is present' },
{ value: 'is_not_present', label: 'Is not present' },
],
},
{
key: 'contact_custom_attribute',
name: 'Contact Custom Attributes',
disabled: true,
},
{
key: 'contact_list',
name: 'Contact List',
inputType: 'search_select',
filterOperators: [
{ value: 'equal_to', label: 'Equal to' },
{ value: 'not_equal_to', label: 'Not equal to' },
],
},
];
export const slaPolicies = [
{
id: 1,
account_id: 1,
name: 'Low',
first_response_time_threshold: 60,
next_response_time_threshold: 120,
resolution_time_threshold: 240,
created_at: '2022-01-26T08:06:39.470Z',
updated_at: '2022-01-26T08:06:39.470Z',
},
{
id: 2,
account_id: 1,
name: 'Medium',
first_response_time_threshold: 30,
next_response_time_threshold: 60,
resolution_time_threshold: 120,
created_at: '2022-01-26T08:06:39.470Z',
updated_at: '2022-01-26T08:06:39.470Z',
},
{
id: 3,
account_id: 1,
name: 'High',
first_response_time_threshold: 15,
next_response_time_threshold: 30,
resolution_time_threshold: 60,
created_at: '2022-01-26T08:06:39.470Z',
updated_at: '2022-01-26T08:06:39.470Z',
},
{
id: 4,
account_id: 1,
name: 'Urgent',
first_response_time_threshold: 5,
next_response_time_threshold: 10,
resolution_time_threshold: 20,
created_at: '2022-01-26T08:06:39.470Z',
updated_at: '2022-01-26T08:06:39.470Z',
},
];

View File

@@ -0,0 +1,185 @@
export const conversationData = {
meta: {
sender: {
additional_attributes: {
created_at_ip: '127.0.0.1',
},
availability_status: 'offline',
email: null,
id: 5017687,
name: 'long-flower-143',
phone_number: null,
thumbnail: '',
custom_attributes: {},
},
channel: 'Channel::WebWidget',
assignee: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'muhsin@chatwoot.com',
available_name: 'Muhsin Keloth',
id: 21,
name: 'Muhsin Keloth',
role: 'administrator',
thumbnail: 'http://example.com/image.png',
},
},
id: 5815,
messages: [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
],
inbox_id: 37,
status: 'open',
muted: false,
can_reply: true,
timestamp: 1621144123,
contact_last_seen_at: 0,
agent_last_seen_at: 1621144123,
unread_count: 0,
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '90.0.4430.212',
platform_version: '10.15.7',
},
widget_language: null,
browser_language: 'en',
},
account_id: 1,
labels: [],
};
export const lastMessageData = {
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
};
export const readMessagesData = [
{
id: 438072,
content: 'Campaign after 5 seconds',
account_id: 1,
inbox_id: 37,
conversation_id: 5811,
message_type: 1,
created_at: 1620980262,
updated_at: '2021-05-14T08:17:42.041Z',
private: false,
status: 'sent',
source_id: null,
content_type: null,
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
},
];
export const unReadMessagesData = [
{
id: 4382131101,
content: 'Hello',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
{
id: 438100,
content: 'Hey',
account_id: 1,
inbox_id: 37,
conversation_id: 5815,
message_type: 0,
created_at: 1621145476,
updated_at: '2021-05-16T05:48:43.910Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: null,
sender_id: null,
external_source_ids: {},
},
];

View File

@@ -0,0 +1,9 @@
import { getCountryFlag } from '../flag';
describe('#flag', () => {
it('returns the correct flag ', () => {
expect(getCountryFlag('cz')).toBe('🇨🇿');
expect(getCountryFlag('IN')).toBe('🇮🇳');
expect(getCountryFlag('US')).toBe('🇺🇸');
});
});

View File

@@ -0,0 +1,169 @@
import {
INBOX_TYPES,
getInboxClassByType,
getInboxIconByType,
getInboxWarningIconClass,
} from '../inbox';
describe('#Inbox Helpers', () => {
describe('getInboxClassByType', () => {
it('should return correct class for web widget', () => {
expect(getInboxClassByType('Channel::WebWidget')).toEqual(
'globe-desktop'
);
});
it('should return correct class for fb page', () => {
expect(getInboxClassByType('Channel::FacebookPage')).toEqual(
'brand-facebook'
);
});
it('should return correct class for twitter profile', () => {
expect(getInboxClassByType('Channel::TwitterProfile')).toEqual(
'brand-twitter'
);
});
it('should return correct class for twilio sms', () => {
expect(getInboxClassByType('Channel::TwilioSms', '')).toEqual(
'brand-sms'
);
});
it('should return correct class for whatsapp', () => {
expect(getInboxClassByType('Channel::TwilioSms', 'whatsapp')).toEqual(
'brand-whatsapp'
);
});
it('should return correct class for Api', () => {
expect(getInboxClassByType('Channel::Api')).toEqual('cloud');
});
it('should return correct class for Email', () => {
expect(getInboxClassByType('Channel::Email')).toEqual('mail');
});
it('should return correct class for TikTok', () => {
expect(getInboxClassByType(INBOX_TYPES.TIKTOK)).toEqual('brand-tiktok');
});
});
describe('getInboxIconByType', () => {
describe('fill variant (default)', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns correct icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK)).toBe('i-ri-tiktok-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
describe('line variant', () => {
it('returns correct line icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB, null, 'line')).toBe(
'i-woot-website'
);
});
it('returns correct line icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB, null, 'line')).toBe(
'i-woot-messenger'
);
});
it('returns correct line icon for TikTok', () => {
expect(getInboxIconByType(INBOX_TYPES.TIKTOK, null, 'line')).toBe(
'i-woot-tiktok'
);
});
it('returns correct line icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE', null, 'line')).toBe(
'i-ri-chat-1-line'
);
});
});
describe('Twilio cases', () => {
describe('fill variant', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms')).toBe(
'i-ri-chat-1-fill'
);
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
describe('line variant', () => {
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
).toBe('i-woot-whatsapp');
});
it('returns SMS line icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line')).toBe(
'i-ri-chat-1-line'
);
});
});
});
});
describe('getInboxWarningIconClass', () => {
it('should return correct class for warning', () => {
expect(getInboxWarningIconClass('Channel::FacebookPage', true)).toEqual(
'warning'
);
});
});
});

View File

@@ -0,0 +1,61 @@
export default {
customFields: {
pre_chat_message: 'Share your queries or comments here.',
pre_chat_fields: [
{
label: 'Email Address',
name: 'emailAddress',
type: 'email',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your email address',
},
{
label: 'Full Name',
name: 'fullName',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your full name',
},
{
label: 'Phone Number',
name: 'phoneNumber',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
placeholder: 'Please enter your phone number',
},
],
},
customAttributes: [
{
id: 101,
attribute_description: 'Order Identifier',
attribute_display_name: 'Order Id',
attribute_display_type: 'number',
attribute_key: 'order_id',
attribute_model: 'conversation_attribute',
attribute_values: Array(0),
created_at: '2021-11-29T10:20:04.563Z',
},
],
customAttributesWithRegex: [
{
id: 2,
attribute_description: 'Test contact Attribute',
attribute_display_name: 'Test contact Attribute',
attribute_display_type: 'text',
attribute_key: 'test_contact_attribute',
attribute_model: 'contact_attribute',
attribute_values: Array(0),
created_at: '2023-09-20T10:20:04.563Z',
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
],
};

View File

@@ -0,0 +1,122 @@
export const teams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
export const labels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
export const agents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail:
'http://localhost:3000/rails/active_storage/representations/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBUZz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--746506837470c1a3dd063e90211ba2386963d52f/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9MWm05eWJXRjBTU0lJY0c1bkJqb0dSVlE2QzNKbGMybDZaVWtpRERJMU1IZ3lOVEFHT3daVSIsImV4cCI6bnVsbCwicHVyIjoidmFyaWF0aW9uIn19--e0e35266e8ed66e90c51be02408be8a022aca545/batman_90804.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
export const files = [
{
id: 76,
macro_id: 77,
file_type: 'image/jpeg',
account_id: 1,
file_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBYUT09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--aa41b5a779a83c1d86b28475a5cf0bd17f41f0ff/fayaz_cropped.jpeg',
blob_id: 88,
filename: 'fayaz_cropped.jpeg',
},
{
id: 82,
macro_id: 77,
file_type: 'image/png',
account_id: 1,
file_url:
'http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBZdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--260fda80b77409ffaaac10b96681fba447600545/screenshot.png',
blob_id: 94,
filename: 'screenshot.png',
},
];

View File

@@ -0,0 +1,76 @@
import {
emptyMacro,
resolveActionName,
resolveLabels,
resolveTeamIds,
getFileName,
resolveAgents,
} from '../../routes/dashboard/settings/macros/macroHelper';
import { MACRO_ACTION_TYPES } from '../../routes/dashboard/settings/macros/constants';
import { teams, labels, files, agents } from './macrosFixtures';
describe('#emptyMacro', () => {
const defaultMacro = {
name: '',
actions: [
{
action_name: 'assign_team',
action_params: [],
},
],
visibility: 'global',
};
it('returns the default macro', () => {
expect(emptyMacro).toEqual(defaultMacro);
});
});
describe('#resolveActionName', () => {
it('resolve action name from key and return the correct label', () => {
expect(resolveActionName(MACRO_ACTION_TYPES[0].key)).toEqual(
MACRO_ACTION_TYPES[0].label
);
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).toEqual(
MACRO_ACTION_TYPES[1].label
);
expect(resolveActionName(MACRO_ACTION_TYPES[1].key)).not.toEqual(
MACRO_ACTION_TYPES[0].label
);
expect(resolveActionName('change_priority')).toEqual('CHANGE_PRIORITY'); // Translated
});
});
describe('#resolveTeamIds', () => {
it('resolves team names from ids, and returns a joined string', () => {
const resolvedTeams = '⚙️ sales team, 🤷‍♂️ fayaz';
expect(resolveTeamIds(teams, [1, 2])).toEqual(resolvedTeams);
});
});
describe('#resolveLabels', () => {
it('resolves labels names from ids and returns a joined string', () => {
const resolvedLabels = 'sales, billing';
expect(resolveLabels(labels, ['sales', 'billing'])).toEqual(resolvedLabels);
});
});
describe('#resolveAgents', () => {
it('resolves agents names from ids and returns a joined string', () => {
const resolvedAgents = 'John Doe';
expect(resolveAgents(agents, [1])).toEqual(resolvedAgents);
});
});
describe('#getFileName', () => {
it('returns the correct file name from the list of files', () => {
expect(getFileName(files[0].blob_id, 'send_attachment', files)).toEqual(
files[0].filename
);
expect(getFileName(files[1].blob_id, 'send_attachment', files)).toEqual(
files[1].filename
);
expect(getFileName(files[0].blob_id, 'wrong_action', files)).toEqual('');
expect(getFileName(null, 'send_attachment', files)).toEqual('');
expect(getFileName(files[0].blob_id, 'send_attachment', [])).toEqual('');
});
});

View File

@@ -0,0 +1,153 @@
import {
getCurrentAccount,
getUserPermissions,
hasPermissions,
filterItemsByPermission,
} from '../permissionsHelper';
describe('#getCurrentAccount', () => {
it('should return the current account', () => {
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
});
});
describe('#getUserPermissions', () => {
it('should return the correct permissions', () => {
const user = {
accounts: [
{ id: 1, permissions: ['conversations_manage'] },
{ id: 3, permissions: ['contacts_manage'] },
],
};
expect(getUserPermissions(user, 1)).toEqual(['conversations_manage']);
expect(getUserPermissions(user, '3')).toEqual(['contacts_manage']);
expect(getUserPermissions(user, 2)).toEqual([]);
});
});
describe('hasPermissions', () => {
it('returns true if permission is present', () => {
expect(
hasPermissions(['contact_manage'], ['team_manage', 'contact_manage'])
).toBe(true);
});
it('returns true if permission is not present', () => {
expect(
hasPermissions(['contact_manage'], ['team_manage', 'user_manage'])
).toBe(false);
expect(hasPermissions()).toBe(false);
expect(hasPermissions([])).toBe(false);
});
});
describe('filterItemsByPermission', () => {
const items = {
item1: { name: 'Item 1', permissions: ['agent', 'administrator'] },
item2: {
name: 'Item 2',
permissions: [
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
],
},
item3: { name: 'Item 3', permissions: ['contact_manage'] },
item4: { name: 'Item 4', permissions: ['report_manage'] },
item5: { name: 'Item 5', permissions: ['knowledge_base_manage'] },
item6: {
name: 'Item 6',
permissions: [
'agent',
'administrator',
'conversation_manage',
'conversation_unassigned_manage',
'conversation_participating_manage',
'contact_manage',
'report_manage',
'knowledge_base_manage',
],
},
item7: { name: 'Item 7', permissions: [] },
};
const getPermissions = item => item.permissions;
it('filters items based on user permissions', () => {
const userPermissions = ['agent', 'contact_manage', 'report_manage'];
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions
);
expect(result).toHaveLength(5);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item1', name: 'Item 1' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item3', name: 'Item 3' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item4', name: 'Item 4' })
);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item6', name: 'Item 6' })
);
});
it('includes items with empty permissions', () => {
const userPermissions = [];
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions
);
expect(result).toHaveLength(1);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item7', name: 'Item 7' })
);
});
it('uses custom transform function when provided', () => {
const userPermissions = ['agent', 'contact_manage'];
const customTransform = (key, item) => ({ id: key, title: item.name });
const result = filterItemsByPermission(
items,
userPermissions,
getPermissions,
customTransform
);
expect(result).toHaveLength(4);
expect(result).toContainEqual({ id: 'item1', title: 'Item 1' });
expect(result).toContainEqual({ id: 'item3', title: 'Item 3' });
expect(result).toContainEqual({ id: 'item6', title: 'Item 6' });
});
it('handles empty items object', () => {
const result = filterItemsByPermission({}, ['agent'], getPermissions);
expect(result).toHaveLength(0);
});
it('handles custom getPermissions function', () => {
const customItems = {
item1: { name: 'Item 1', requiredPerms: ['agent', 'administrator'] },
item2: { name: 'Item 2', requiredPerms: ['contact_manage'] },
};
const customGetPermissions = item => item.requiredPerms;
const result = filterItemsByPermission(
customItems,
['agent'],
customGetPermissions
);
expect(result).toHaveLength(1);
expect(result).toContainEqual(
expect.objectContaining({ key: 'item1', name: 'Item 1' })
);
});
});

View File

@@ -0,0 +1,71 @@
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
describe('PortalHelper', () => {
describe('buildPortalURL', () => {
it('returns the correct url', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(buildPortalURL('handbook')).toEqual(
'https://help.chatwoot.com/hc/handbook'
);
window.chatwootConfig = {};
});
});
describe('buildPortalArticleURL', () => {
it('returns the correct url', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
).toEqual('https://help.chatwoot.com/hc/handbook/articles/article-slug');
window.chatwootConfig = {};
});
it('returns the correct url with custom domain', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(
buildPortalArticleURL(
'handbook',
'culture',
'fr',
'article-slug',
'custom-domain.dev'
)
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
});
it('handles https in custom domain correctly', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: 'https://help.chatwoot.com',
};
expect(
buildPortalArticleURL(
'handbook',
'culture',
'fr',
'article-slug',
'https://custom-domain.dev'
)
).toEqual('https://custom-domain.dev/hc/handbook/articles/article-slug');
});
it('uses hostURL when helpCenterURL is not available', () => {
window.chatwootConfig = {
hostURL: 'https://app.chatwoot.com',
helpCenterURL: '',
};
expect(
buildPortalArticleURL('handbook', 'culture', 'fr', 'article-slug')
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
});
});
});

View File

@@ -0,0 +1,96 @@
import {
getPreChatFields,
getFormattedPreChatFields,
getCustomFields,
} from '../preChat';
import inboxFixture from './inboxFixture';
const { customFields, customAttributes, customAttributesWithRegex } =
inboxFixture;
describe('#Pre chat Helpers', () => {
describe('getPreChatFields', () => {
it('should return correct pre-chat fields form options passed', () => {
expect(getPreChatFields({ preChatFormOptions: customFields })).toEqual(
customFields
);
});
});
describe('getFormattedPreChatFields', () => {
it('should return correct custom fields', () => {
expect(
getFormattedPreChatFields({
preChatFields: customFields.pre_chat_fields,
})
).toEqual([
{
label: 'Email Address',
name: 'emailAddress',
placeholder: 'Please enter your email address',
type: 'email',
field_type: 'standard',
required: false,
enabled: false,
},
{
label: 'Full Name',
name: 'fullName',
placeholder: 'Please enter your full name',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
},
{
label: 'Phone Number',
name: 'phoneNumber',
placeholder: 'Please enter your phone number',
type: 'text',
field_type: 'standard',
required: false,
enabled: false,
},
]);
});
});
describe('getCustomFields', () => {
it('should return correct custom fields', () => {
expect(
getCustomFields({
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
customAttributes,
})
).toEqual([
{
enabled: false,
label: 'Order Id',
placeholder: 'Order Id',
name: 'order_id',
required: false,
field_type: 'conversation_attribute',
type: 'number',
values: [],
},
]);
expect(
getCustomFields({
standardFields: { pre_chat_fields: customFields.pre_chat_fields },
customAttributes: customAttributesWithRegex,
})
).toEqual([
{
enabled: false,
label: 'Test contact Attribute',
placeholder: 'Test contact Attribute',
name: 'test_contact_attribute',
required: false,
field_type: 'contact_attribute',
type: 'text',
values: [],
regex_pattern: '^w+$',
regex_cue: 'It should be a combination of alphabets and numbers',
},
]);
});
});
});

View File

@@ -0,0 +1,461 @@
import {
extractPlainTextFromHtml,
getEmailSenderName,
getEmailSenderEmail,
getEmailDate,
formatQuotedEmailDate,
getInboxEmail,
buildQuotedEmailHeader,
buildQuotedEmailHeaderFromContact,
buildQuotedEmailHeaderFromInbox,
formatQuotedTextAsBlockquote,
extractQuotedEmailText,
truncatePreviewText,
appendQuotedTextToMessage,
} from '../quotedEmailHelper';
describe('quotedEmailHelper', () => {
describe('extractPlainTextFromHtml', () => {
it('returns empty string for null or undefined', () => {
expect(extractPlainTextFromHtml(null)).toBe('');
expect(extractPlainTextFromHtml(undefined)).toBe('');
});
it('strips HTML tags and returns plain text', () => {
const html = '<p>Hello <strong>world</strong></p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello world');
});
it('handles complex HTML structure', () => {
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
const result = extractPlainTextFromHtml(html);
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', () => {
it('returns sender name from lastEmail', () => {
const lastEmail = { sender: { name: 'John Doe' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
it('returns contact name if sender name not available', () => {
const lastEmail = { sender: {} };
const contact = { name: 'Jane Smith' };
const result = getEmailSenderName(lastEmail, contact);
expect(result).toBe('Jane Smith');
});
it('returns empty string if neither available', () => {
const result = getEmailSenderName({}, {});
expect(result).toBe('');
});
it('trims whitespace from names', () => {
const lastEmail = { sender: { name: ' John Doe ' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
});
describe('getEmailSenderEmail', () => {
it('returns sender email from lastEmail', () => {
const lastEmail = { sender: { email: 'john@example.com' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
it('returns email from contentAttributes if sender email not available', () => {
const lastEmail = {
contentAttributes: {
email: { from: ['jane@example.com'] },
},
};
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('jane@example.com');
});
it('returns contact email as fallback', () => {
const lastEmail = {};
const contact = { email: 'contact@example.com' };
const result = getEmailSenderEmail(lastEmail, contact);
expect(result).toBe('contact@example.com');
});
it('trims whitespace from emails', () => {
const lastEmail = { sender: { email: ' john@example.com ' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
});
describe('getEmailDate', () => {
it('returns parsed date from email metadata', () => {
const lastEmail = {
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns date from created_at timestamp', () => {
const lastEmail = { created_at: 1705318200 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('handles millisecond timestamps', () => {
const lastEmail = { created_at: 1705318200000 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns null if no valid date found', () => {
const result = getEmailDate({});
expect(result).toBeNull();
});
});
describe('formatQuotedEmailDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15T10:30:00Z');
const result = formatQuotedEmailDate(date);
expect(result).toMatch(/Mon, Jan 15, 2024 at/);
});
it('returns empty string for invalid date', () => {
const result = formatQuotedEmailDate('invalid');
expect(result).toBe('');
});
});
describe('getInboxEmail', () => {
it('returns email from contentAttributes.email.to', () => {
const lastEmail = {
contentAttributes: {
email: { to: ['inbox@example.com'] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
it('returns inbox email as fallback', () => {
const lastEmail = {};
const inbox = { email: 'support@example.com' };
const result = getInboxEmail(lastEmail, inbox);
expect(result).toBe('support@example.com');
});
it('returns empty string if no email found', () => {
expect(getInboxEmail({}, {})).toBe('');
});
it('trims whitespace from emails', () => {
const lastEmail = {
contentAttributes: {
email: { to: [' inbox@example.com '] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
});
describe('buildQuotedEmailHeaderFromContact', () => {
it('builds complete header with name and email', () => {
const lastEmail = {
sender: { name: 'John Doe', email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('John Doe');
expect(result).toContain('john@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
sender: { email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('<john@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromContact(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromContact({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeaderFromInbox', () => {
it('builds complete header with inbox name and email', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support Team', email: 'support@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('Support Team');
expect(result).toContain('support@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['inbox@example.com'],
},
},
};
const inbox = { email: 'inbox@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('<inbox@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromInbox(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromInbox({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeader', () => {
it('uses inbox email for outgoing messages (message_type: 1)', () => {
const lastEmail = {
message_type: 1,
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'John Doe', email: 'john@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Support');
expect(result).toContain('support@example.com');
expect(result).not.toContain('John Doe');
});
it('uses contact email for incoming messages (message_type: 0)', () => {
const lastEmail = {
message_type: 0,
sender: { name: 'Jane Smith', email: 'jane@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'Jane Smith', email: 'jane@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Jane Smith');
expect(result).toContain('jane@example.com');
expect(result).not.toContain('Support');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeader(null, {}, {})).toBe('');
expect(buildQuotedEmailHeader({}, {}, {})).toBe('');
});
});
describe('formatQuotedTextAsBlockquote', () => {
it('formats single line text', () => {
const result = formatQuotedTextAsBlockquote('Hello world');
expect(result).toBe('> Hello world');
});
it('formats multi-line text', () => {
const text = 'Line 1\nLine 2\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2\n> Line 3');
});
it('includes header if provided', () => {
const result = formatQuotedTextAsBlockquote('Hello', 'Header text');
expect(result).toContain('> Header text');
expect(result).toContain('>\n> Hello');
});
it('handles empty lines correctly', () => {
const text = 'Line 1\n\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n>\n> Line 3');
});
it('returns empty string for empty input', () => {
expect(formatQuotedTextAsBlockquote('')).toBe('');
expect(formatQuotedTextAsBlockquote('', '')).toBe('');
});
it('handles Windows line endings', () => {
const text = 'Line 1\r\nLine 2';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2');
});
});
describe('extractQuotedEmailText', () => {
it('extracts text from textContent.reply', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { reply: 'Reply text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Reply text');
});
it('falls back to textContent.full', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { full: 'Full text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Full text');
});
it('extracts from htmlContent and converts to plain text', () => {
const lastEmail = {
contentAttributes: {
email: { htmlContent: { reply: '<p>HTML reply</p>' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('HTML reply');
});
it('uses fallback content if structured content not available', () => {
const lastEmail = { content: 'Fallback content' };
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Fallback content');
});
it('returns empty string for null or missing email', () => {
expect(extractQuotedEmailText(null)).toBe('');
expect(extractQuotedEmailText({})).toBe('');
});
});
describe('truncatePreviewText', () => {
it('returns full text if under max length', () => {
const text = 'Short text';
const result = truncatePreviewText(text, 80);
expect(result).toBe('Short text');
});
it('truncates text exceeding max length', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text, 80);
expect(result).toHaveLength(80);
expect(result).toContain('...');
});
it('collapses multiple spaces', () => {
const text = 'Text with spaces';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('trims whitespace', () => {
const text = ' Text with spaces ';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('returns empty string for empty input', () => {
expect(truncatePreviewText('')).toBe('');
expect(truncatePreviewText(' ')).toBe('');
});
it('uses default max length of 80', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text);
expect(result).toHaveLength(80);
});
});
describe('appendQuotedTextToMessage', () => {
it('appends quoted text to message', () => {
const message = 'My reply';
const quotedText = 'Original message';
const header = 'On date sender wrote:';
const result = appendQuotedTextToMessage(message, quotedText, header);
expect(result).toContain('My reply');
expect(result).toContain('> On date sender wrote:');
expect(result).toContain('> Original message');
});
it('returns only quoted text if message is empty', () => {
const result = appendQuotedTextToMessage('', 'Quoted', 'Header');
expect(result).toContain('> Header');
expect(result).toContain('> Quoted');
expect(result).not.toContain('\n\n\n');
});
it('returns message if no quoted text', () => {
const result = appendQuotedTextToMessage('Message', '', '');
expect(result).toBe('Message');
});
it('handles proper spacing with double newline', () => {
const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
it('does not add extra newlines if message already ends with newlines', () => {
const result = appendQuotedTextToMessage(
'Message\n\n',
'Quoted',
'Header'
);
expect(result).not.toContain('\n\n\n');
});
it('adds single newline if message ends with one newline', () => {
const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
});
});

View File

@@ -0,0 +1,241 @@
import {
getConversationDashboardRoute,
isAConversationRoute,
defaultRedirectPage,
routeIsAccessibleFor,
validateLoggedInRoutes,
isAInboxViewRoute,
} from '../routeHelpers';
describe('#routeIsAccessibleFor', () => {
it('should return the correct access', () => {
let route = { meta: { permissions: ['administrator'] } };
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false);
expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true);
});
});
describe('#defaultRedirectPage', () => {
const to = {
params: { accountId: '2' },
fullPath: '/app/accounts/2/dashboard',
name: 'home',
};
it('should return dashboard route for users with conversation permissions', () => {
const permissions = ['conversation_manage', 'agent'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return contacts route for users with contact permissions', () => {
const permissions = ['contact_manage'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/contacts');
});
it('should return reports route for users with report permissions', () => {
const permissions = ['report_manage'];
expect(defaultRedirectPage(to, permissions)).toBe(
'accounts/2/reports/overview'
);
});
it('should return portals route for users with portal permissions', () => {
const permissions = ['knowledge_base_manage'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/portals');
});
it('should return dashboard route as default for users with custom roles', () => {
const permissions = ['custom_role'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return dashboard route for users with administrator role', () => {
const permissions = ['administrator'];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
it('should return dashboard route for users with multiple permissions', () => {
const permissions = [
'contact_manage',
'custom_role',
'conversation_manage',
'agent',
'administrator',
];
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
});
});
describe('#validateLoggedInRoutes', () => {
describe('when account access is missing', () => {
it('should return the login route', () => {
expect(
validateLoggedInRoutes({ params: { accountId: 1 } }, { accounts: [] })
).toEqual(`app/login`);
});
});
describe('when account access is available', () => {
describe('when account is suspended', () => {
it('return suspended route', () => {
expect(
validateLoggedInRoutes(
{
name: 'conversations',
params: { accountId: 1 },
meta: { permissions: ['agent'] },
},
{ accounts: [{ id: 1, role: 'agent', status: 'suspended' }] }
)
).toEqual(`accounts/1/suspended`);
});
});
describe('when account is active', () => {
describe('when route is accessible', () => {
it('returns null (no action required)', () => {
expect(
validateLoggedInRoutes(
{
name: 'conversations',
params: { accountId: 1 },
meta: { permissions: ['agent'] },
},
{
permissions: ['agent'],
accounts: [
{
id: 1,
role: 'agent',
permissions: ['agent'],
status: 'active',
},
],
}
)
).toEqual(null);
});
});
describe('when route is not accessible', () => {
it('returns dashboard url', () => {
expect(
validateLoggedInRoutes(
{
name: 'billing',
params: { accountId: 1 },
meta: { permissions: ['administrator'] },
},
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
)
).toEqual(`accounts/1/dashboard`);
});
});
describe('when route is suspended route', () => {
it('returns dashboard url', () => {
expect(
validateLoggedInRoutes(
{ name: 'account_suspended', params: { accountId: 1 } },
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
)
).toEqual(`accounts/1/dashboard`);
});
});
});
});
});
describe('isAConversationRoute', () => {
it('returns true if conversation route name is provided', () => {
expect(isAConversationRoute('inbox_conversation')).toBe(true);
expect(isAConversationRoute('conversation_through_inbox')).toBe(true);
expect(isAConversationRoute('conversations_through_label')).toBe(true);
expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false);
});
it('returns true if base conversation route name is provided and includeBase is true', () => {
expect(isAConversationRoute('home', true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
expect(isAConversationRoute('label_conversations', true)).toBe(true);
expect(isAConversationRoute('team_conversations', true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
});
it('returns false if base conversation route name is provided and includeBase is false', () => {
expect(isAConversationRoute('home', false)).toBe(false);
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
expect(isAConversationRoute('label_conversations', false)).toBe(false);
expect(isAConversationRoute('team_conversations', false)).toBe(false);
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
expect(isAConversationRoute('conversation_participating', false)).toBe(
false
);
});
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
expect(isAConversationRoute('home', true, true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
true
);
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
true
);
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
true
);
});
it('returns false if base conversation route name is not provided', () => {
expect(isAConversationRoute('')).toBe(false);
});
});
describe('getConversationDashboardRoute', () => {
it('returns dashboard route for conversation', () => {
expect(getConversationDashboardRoute('inbox_conversation')).toEqual('home');
expect(
getConversationDashboardRoute('conversation_through_mentions')
).toEqual('conversation_mentions');
expect(
getConversationDashboardRoute('conversation_through_unattended')
).toEqual('conversation_unattended');
expect(
getConversationDashboardRoute('conversations_through_label')
).toEqual('label_conversations');
expect(getConversationDashboardRoute('conversations_through_team')).toEqual(
'team_conversations'
);
expect(
getConversationDashboardRoute('conversations_through_folders')
).toEqual('folder_conversations');
expect(
getConversationDashboardRoute('conversation_through_participating')
).toEqual('conversation_participating');
expect(getConversationDashboardRoute('conversation_through_inbox')).toEqual(
'inbox_dashboard'
);
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
});
});
describe('isAInboxViewRoute', () => {
it('returns true if inbox view route name is provided', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
});
it('returns true if base inbox view route name is provided and includeBase is true', () => {
expect(isAInboxViewRoute('inbox_view', true)).toBe(true);
});
it('returns false if base inbox view route name is provided and includeBase is false', () => {
expect(isAInboxViewRoute('inbox_view')).toBe(false);
});
});

View File

@@ -0,0 +1,153 @@
import {
findSnoozeTime,
snoozedReopenTime,
findStartOfNextWeek,
findStartOfNextMonth,
findNextDay,
setHoursToNine,
snoozedReopenTimeToTimestamp,
shortenSnoozeTime,
} from '../snoozeHelpers';
describe('#Snooze Helpers', () => {
describe('findStartOfNextWeek', () => {
it('should return first working day of next week if a date is passed', () => {
const today = new Date('06/16/2023');
const startOfNextWeek = new Date('06/19/2023');
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
});
it('should return first working day of next week if a date is passed', () => {
const today = new Date('06/03/2023');
const startOfNextWeek = new Date('06/05/2023');
expect(findStartOfNextWeek(today)).toEqual(startOfNextWeek);
});
});
describe('findStartOfNextMonth', () => {
it('should return first working day of next month if a valid date is passed', () => {
const today = new Date('06/21/2023');
const startOfNextMonth = new Date('07/03/2023');
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
});
it('should return first working day of next month if a valid date is passed', () => {
const today = new Date('02/28/2023');
const startOfNextMonth = new Date('03/06/2023');
expect(findStartOfNextMonth(today)).toEqual(startOfNextMonth);
});
});
describe('setHoursToNine', () => {
it('should return date with 9.00AM time', () => {
const nextDay = new Date('06/17/2023');
nextDay.setHours(9, 0, 0, 0);
expect(setHoursToNine(nextDay)).toEqual(nextDay);
});
it('should return date with 9.00AM time if date with 10am is passes', () => {
const nextDay = new Date('06/17/2023 10:00:00');
nextDay.setHours(9, 0, 0, 0);
expect(setHoursToNine(nextDay)).toEqual(nextDay);
});
});
describe('findSnoozeTime', () => {
it('should return nil if until_next_reply is passed', () => {
expect(findSnoozeTime('until_next_reply')).toEqual(null);
});
it('should return next hour time stamp if an_hour_from_now is passed', () => {
const nextHour = new Date();
nextHour.setHours(nextHour.getHours() + 1);
expect(findSnoozeTime('an_hour_from_now')).toBeCloseTo(
Math.floor(nextHour.getTime() / 1000)
);
});
it('should return next day 9.00AM time stamp until_tomorrow is passed', () => {
const today = new Date('06/16/2023');
const nextDay = new Date('06/17/2023');
nextDay.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_tomorrow', today)).toBeCloseTo(
nextDay.getTime() / 1000
);
});
it('should return next week monday 9.00AM time stamp if until_next_week is passed', () => {
const today = new Date('06/16/2023');
const startOfNextWeek = new Date('06/19/2023');
startOfNextWeek.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_next_week', today)).toBeCloseTo(
startOfNextWeek.getTime() / 1000
);
});
it('should return next month 9.00AM time stamp if until_next_month is passed', () => {
const today = new Date('06/21/2023');
const startOfNextMonth = new Date('07/03/2023');
startOfNextMonth.setHours(9, 0, 0, 0);
expect(findSnoozeTime('until_next_month', today)).toBeCloseTo(
startOfNextMonth.getTime() / 1000
);
});
});
describe('snoozedReopenTime', () => {
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTime(null)).toEqual(null);
});
it('should return formatted date if snoozedUntil is not nil', () => {
expect(snoozedReopenTime('2023-06-07T09:00:00.000Z')).toEqual(
'7 Jun, 9.00am'
);
});
});
describe('findNextDay', () => {
it('should return next day', () => {
const today = new Date('06/16/2023');
const nextDay = new Date('06/17/2023');
expect(findNextDay(today)).toEqual(nextDay);
});
});
describe('snoozedReopenTimeToTimestamp', () => {
it('should return timestamp if snoozedUntil is not nil', () => {
expect(snoozedReopenTimeToTimestamp('2023-06-07T09:00:00.000Z')).toEqual(
1686128400
);
});
it('should return nil if snoozedUntil is nil', () => {
expect(snoozedReopenTimeToTimestamp(null)).toEqual(null);
});
});
describe('shortenSnoozeTime', () => {
it('should return shortened time if snoozedUntil is not nil and day is passed', () => {
expect(shortenSnoozeTime('1 day')).toEqual('1d');
});
it('should return shortened time if snoozedUntil is not nil and month is passed', () => {
expect(shortenSnoozeTime('1 month')).toEqual('1mo');
});
it('should return shortened time if snoozedUntil is not nil and year is passed', () => {
expect(shortenSnoozeTime('1 year')).toEqual('1y');
});
it('should return shortened time if snoozedUntil is not nil and hour is passed', () => {
expect(shortenSnoozeTime('1 hour')).toEqual('1h');
});
it('should return shortened time if snoozedUntil is not nil and minutes is passed', () => {
expect(shortenSnoozeTime('1 minutes')).toEqual('1m');
});
it('should return shortened time if snoozedUntil is not nil and in is passed', () => {
expect(shortenSnoozeTime('in 1 hour')).toEqual('1h');
});
it('should return nil if snoozedUntil is nil', () => {
expect(shortenSnoozeTime(null)).toEqual(null);
});
});
});

View File

@@ -0,0 +1,369 @@
import {
replaceTemplateVariables,
buildTemplateParameters,
processVariable,
allKeysRequired,
} from '../templateHelper';
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
describe('templateHelper', () => {
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
describe('processVariable', () => {
it('should remove curly braces from variables', () => {
expect(processVariable('{{name}}')).toBe('name');
expect(processVariable('{{1}}')).toBe('1');
expect(processVariable('{{customer_id}}')).toBe('customer_id');
});
});
describe('allKeysRequired', () => {
it('should return true when all keys have values', () => {
const obj = { name: 'John', age: '30' };
expect(allKeysRequired(obj)).toBe(true);
});
it('should return false when some keys are empty', () => {
const obj = { name: 'John', age: '' };
expect(allKeysRequired(obj)).toBe(false);
});
it('should return true for empty object', () => {
expect(allKeysRequired({})).toBe(true);
});
});
describe('replaceTemplateVariables', () => {
const templateText =
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
it('should replace all variables with provided values', () => {
const processedParams = {
body: {
1: 'John',
2: '123 Main St',
3: '2025-01-15',
4: '10:00 AM',
5: '2:00 PM',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
);
});
it('should keep original variable format when no replacement value provided', () => {
const processedParams = {
body: {
1: 'John',
3: '2025-01-15',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toContain('John');
expect(result).toContain('2025-01-15');
expect(result).toContain('{{2}}');
expect(result).toContain('{{4}}');
expect(result).toContain('{{5}}');
});
it('should handle empty processedParams', () => {
const result = replaceTemplateVariables(templateText, {});
expect(result).toBe(templateText);
});
});
describe('buildTemplateParameters', () => {
it('should build parameters for template with body variables', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.body).toEqual({
1: '',
2: '',
3: '',
4: '',
5: '',
});
});
it('should include header parameters when hasMediaHeader is true', () => {
const imageTemplate = templates.find(
t => t.name === 'order_confirmation'
);
const result = buildTemplateParameters(imageTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
});
it('should not include header parameters when hasMediaHeader is false', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.header).toBeUndefined();
});
it('should handle template with no body component', () => {
const templateWithoutBody = {
components: [{ type: 'HEADER', format: 'TEXT' }],
};
const result = buildTemplateParameters(templateWithoutBody, false);
expect(result).toEqual({});
});
it('should handle template with no variables', () => {
const templateWithoutVars = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(templateWithoutVars, false);
expect(result.body).toBeUndefined();
});
it('should handle URL buttons with variables for non-authentication templates', () => {
const templateWithUrlButton = {
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'Check out our website at {{site_url}}',
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
url: 'https://example.com/{{campaign_id}}',
text: 'Visit Site',
},
],
},
],
};
const result = buildTemplateParameters(templateWithUrlButton, false);
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{campaign_id}}',
variables: ['campaign_id'],
},
]);
});
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
const complexTemplate = {
components: [
{ type: 'HEADER', format: 'IMAGE' },
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
{ type: 'FOOTER', text: 'Thank you for your business' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
},
],
};
const result = buildTemplateParameters(complexTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
expect(result.body).toEqual({ 1: '', 2: '' });
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{3}}',
variables: ['3'],
},
]);
});
it('should handle copy code buttons correctly', () => {
const copyCodeTemplate = templates.find(
t => t.name === 'discount_coupon'
);
const result = buildTemplateParameters(copyCodeTemplate, false);
expect(result.body).toBeDefined();
expect(result.buttons).toEqual([
{
type: 'copy_code',
parameter: '',
},
]);
});
it('should handle templates with document headers', () => {
const documentTemplate = templates.find(
t => t.name === 'purchase_receipt'
);
const result = buildTemplateParameters(documentTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
media_name: '',
});
expect(result.body).toEqual({
1: '',
2: '',
3: '',
});
});
it('should handle video header templates', () => {
const videoTemplate = templates.find(t => t.name === 'training_video');
const result = buildTemplateParameters(videoTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
name: '',
date: '',
});
});
});
describe('enhanced format validation', () => {
it('should validate enhanced format structure', () => {
const processedParams = {
body: { 1: 'John', 2: 'Order123' },
header: {
media_url: 'https://example.com/image.jpg',
media_type: 'image',
},
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
};
// Test that structure is properly formed
expect(processedParams.body).toBeDefined();
expect(typeof processedParams.body).toBe('object');
expect(processedParams.header).toBeDefined();
expect(Array.isArray(processedParams.buttons)).toBe(true);
});
it('should handle empty component sections', () => {
const processedParams = {
body: {},
header: {},
buttons: [],
};
expect(allKeysRequired(processedParams.body)).toBe(true);
expect(allKeysRequired(processedParams.header)).toBe(true);
expect(processedParams.buttons.length).toBe(0);
});
it('should validate parameter completeness', () => {
const incompleteParams = {
body: { 1: 'John', 2: '' },
};
expect(allKeysRequired(incompleteParams.body)).toBe(false);
});
it('should handle edge cases in processVariable', () => {
expect(processVariable('{{')).toBe('');
expect(processVariable('}}')).toBe('');
expect(processVariable('')).toBe('');
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
});
it('should handle special characters in template variables', () => {
/* eslint-disable no-template-curly-in-string */
const templateText =
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
/* eslint-enable no-template-curly-in-string */
const processedParams = {
body: {
user_name: 'John & Jane',
order_id: '12345',
amount: '99.99',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
'Welcome John & Jane, your order #12345 costs $99.99'
);
});
it('should handle templates with mixed parameter types', () => {
const mixedTemplate = {
components: [
{ type: 'HEADER', format: 'VIDEO' },
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
{ type: 'FOOTER', text: 'Thank you' },
{
type: 'BUTTONS',
buttons: [
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
{ type: 'COPY_CODE' },
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
],
},
],
};
const result = buildTemplateParameters(mixedTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
order_id: '',
status: '',
});
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
expect(result.buttons[0].type).toBe('url');
expect(result.buttons[1].type).toBe('copy_code');
});
it('should handle templates with no processable components', () => {
const emptyTemplate = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
{ type: 'BODY', text: 'Static body with no variables' },
{ type: 'FOOTER', text: 'Static footer' },
],
};
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
const partialParams = {
body: {
name: 'John',
// order_id missing
status: 'ready',
},
};
const result = replaceTemplateVariables(templateText, partialParams);
expect(result).toBe('Hi John, order {{order_id}} is ready');
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
});
});
});

View File

@@ -0,0 +1,76 @@
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
import { LocalStorage } from 'shared/helpers/localStorage';
vi.mock('shared/helpers/localStorage');
describe('setColorTheme', () => {
it('should set body class to dark if selectedColorScheme is dark', () => {
LocalStorage.get.mockReturnValue('dark');
setColorTheme(true);
expect(document.body.classList.contains('dark')).toBe(true);
});
it('should set body class to dark if selectedColorScheme is auto and isOSOnDarkMode is true', () => {
LocalStorage.get.mockReturnValue('auto');
setColorTheme(true);
expect(document.body.classList.contains('dark')).toBe(true);
});
it('should not set body class to dark if selectedColorScheme is auto and isOSOnDarkMode is false', () => {
LocalStorage.get.mockReturnValue('auto');
setColorTheme(false);
expect(document.body.classList.contains('dark')).toBe(false);
});
it('should not set body class to dark if selectedColorScheme is light', () => {
LocalStorage.get.mockReturnValue('light');
setColorTheme(true);
expect(document.body.classList.contains('dark')).toBe(false);
});
it('should not set body class to dark if selectedColorScheme is undefined', () => {
LocalStorage.get.mockReturnValue(undefined);
setColorTheme(true);
expect(document.body.classList.contains('dark')).toBe(true);
});
it('should set documentElement style to dark if selectedColorScheme is dark', () => {
LocalStorage.get.mockReturnValue('dark');
setColorTheme(true);
expect(document.documentElement.getAttribute('style')).toBe(
'color-scheme: dark;'
);
});
it('should set documentElement style to dark if selectedColorScheme is auto and isOSOnDarkMode is true', () => {
LocalStorage.get.mockReturnValue('auto');
setColorTheme(true);
expect(document.documentElement.getAttribute('style')).toBe(
'color-scheme: dark;'
);
});
it('should set documentElement style to light if selectedColorScheme is auto and isOSOnDarkMode is false', () => {
LocalStorage.get.mockReturnValue('auto');
setColorTheme(false);
expect(document.documentElement.getAttribute('style')).toBe(
'color-scheme: light;'
);
});
it('should set documentElement style to light if selectedColorScheme is light', () => {
LocalStorage.get.mockReturnValue('light');
setColorTheme(true);
expect(document.documentElement.getAttribute('style')).toBe(
'color-scheme: light;'
);
});
it('should set documentElement style to light if selectedColorScheme is undefined', () => {
LocalStorage.get.mockReturnValue(undefined);
setColorTheme(true);
expect(document.documentElement.getAttribute('style')).toBe(
'color-scheme: dark;'
);
});
});

View File

@@ -0,0 +1,93 @@
import axios from 'axios';
import { uploadExternalImage, uploadFile } from '../uploadHelper';
global.axios = axios;
vi.mock('axios');
describe('Upload Helpers', () => {
afterEach(() => {
axios.post.mockReset();
});
describe('uploadFile', () => {
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
describe('uploadExternalImage', () => {
it('should send a POST request with correct data', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadExternalImage(mockUrl, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
{ external_url: mockUrl },
{ headers: { 'Content-Type': 'application/json' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
it('should handle errors', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
'Failed to upload'
);
});
});
});

View File

@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import { validateAutomation } from '../validations';
describe('validateAutomation', () => {
it('should return no errors for a valid automation', () => {
const validAutomation = {
name: 'Test Automation',
description: 'A test automation',
event_name: 'message_created',
conditions: [
{
attribute_key: 'content',
filter_operator: 'contains',
values: 'hello',
},
],
actions: [
{ action_name: 'send_message', action_params: ['Hello there!'] },
],
};
const errors = validateAutomation(validAutomation);
expect(errors).toEqual({});
});
it('should return errors for missing basic fields', () => {
const invalidAutomation = {
name: '',
description: '',
event_name: '',
conditions: [],
actions: [],
};
const errors = validateAutomation(invalidAutomation);
expect(errors).toHaveProperty('name');
expect(errors).toHaveProperty('description');
expect(errors).toHaveProperty('event_name');
});
it('should return errors for invalid conditions', () => {
const automationWithInvalidConditions = {
name: 'Test',
description: 'Test',
event_name: 'message_created',
conditions: [{ attribute_key: '', filter_operator: '', values: '' }],
actions: [{ action_name: 'send_message', action_params: ['Hello'] }],
};
const errors = validateAutomation(automationWithInvalidConditions);
expect(errors).toHaveProperty('condition_0');
});
it('should return errors for invalid actions', () => {
const automationWithInvalidActions = {
name: 'Test',
description: 'Test',
event_name: 'message_created',
conditions: [
{
attribute_key: 'content',
filter_operator: 'contains',
values: 'hello',
},
],
actions: [{ action_name: 'send_message', action_params: [] }],
};
const errors = validateAutomation(automationWithInvalidActions);
expect(errors).toHaveProperty('action_0');
});
it('should not require action params for specific actions', () => {
const automationWithNoParamAction = {
name: 'Test',
description: 'Test',
event_name: 'message_created',
conditions: [
{
attribute_key: 'content',
filter_operator: 'contains',
values: 'hello',
},
],
actions: [{ action_name: 'mute_conversation' }],
};
const errors = validateAutomation(automationWithNoParamAction);
expect(errors).toEqual({});
});
});

View File

@@ -0,0 +1,30 @@
/* eslint no-console: 0 */
/* eslint no-param-reassign: 0 */
export default Vuex => {
const wootState = new Vuex.Store({
state: {
authenticated: false,
currentChat: null,
},
mutations: {
// Authentication mutations
authenticate(state) {
state.authenticated = true;
},
logout(state) {
state.authenticated = false;
},
// CurrentChat Mutations
setCurrentChat(state, chat) {
state.currentChat = chat;
},
},
getters: {
currentChat(state) {
return state.currentChat;
},
},
});
return wootState;
};

View File

@@ -0,0 +1,96 @@
// Constants
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
export const replaceTemplateVariables = (templateText, processedParams) => {
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.body?.[variableKey] || `{{${variable}}}`;
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
// For document templates, include media_name field for filename support
if (headerComponent.format.toLowerCase() === 'document') {
allVariables.header.media_name = '';
}
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};

View File

@@ -0,0 +1,17 @@
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
export const setColorTheme = isOSOnDarkMode => {
const selectedColorScheme =
LocalStorage.get(LOCAL_STORAGE_KEYS.COLOR_SCHEME) || 'auto';
if (
(selectedColorScheme === 'auto' && isOSOnDarkMode) ||
selectedColorScheme === 'dark'
) {
document.body.classList.add('dark');
document.documentElement.style.setProperty('color-scheme', 'dark');
} else {
document.body.classList.remove('dark');
document.documentElement.style.setProperty('color-scheme', 'light');
}
};

View File

@@ -0,0 +1,70 @@
/* global axios */
/**
* Constants and Configuration
*/
// Version for the API endpoint.
const API_VERSION = 'v1';
// Default headers to be used in the axios request.
const HEADERS = {
'Content-Type': 'multipart/form-data',
};
/**
* Uploads a file to the server.
*
* This function sends a POST request to a given API endpoint and uploads the specified file.
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file, accountId) {
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
// Append the file to the FormData instance under the key 'attachment'.
let formData = new FormData();
formData.append('attachment', file);
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
formData,
{ headers: HEADERS }
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}
/**
* Uploads an image from an external URL.
*
* @param {string} url - The external URL of the image.
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response.
*/
export async function uploadExternalImage(url, accountId) {
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
{ external_url: url },
{ headers: { 'Content-Type': 'application/json' } }
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}

View File

@@ -0,0 +1,189 @@
export const ATTRIBUTE_KEY_REQUIRED = 'ATTRIBUTE_KEY_REQUIRED';
export const FILTER_OPERATOR_REQUIRED = 'FILTER_OPERATOR_REQUIRED';
export const VALUE_REQUIRED = 'VALUE_REQUIRED';
export const VALUE_MUST_BE_BETWEEN_1_AND_998 =
'VALUE_MUST_BE_BETWEEN_1_AND_998';
export const ACTION_PARAMETERS_REQUIRED = 'ACTION_PARAMETERS_REQUIRED';
export const ATLEAST_ONE_CONDITION_REQUIRED = 'ATLEAST_ONE_CONDITION_REQUIRED';
export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
const isEmptyValue = value => {
if (!value) {
return true;
}
if (Array.isArray(value)) {
return !value.length;
}
// We can safely check the type here as both the null value
// and the array is ruled out earlier.
if (typeof value === 'object') {
return !Object.keys(value).length;
}
return false;
};
// ------------------------------------------------------------------
// ------------------------ Filter Validation -----------------------
// ------------------------------------------------------------------
/**
* Validates a single filter for conversations or contacts.
*
* @param {Object} filter - The filter object to validate.
* @param {string} filter.attribute_key - The key of the attribute to filter on.
* @param {string} filter.filter_operator - The operator to use for filtering.
* @param {string|number|Array} [filter.values] - The value(s) to filter by (required for most operators).
*
* @returns {string|null} An error message if validation fails, or null if validation passes.
*/
export const validateSingleFilter = filter => {
if (!filter.attribute_key) {
return ATTRIBUTE_KEY_REQUIRED;
}
if (!filter.filter_operator) {
return FILTER_OPERATOR_REQUIRED;
}
const operatorRequiresValue = !['is_present', 'is_not_present'].includes(
filter.filter_operator
);
if (operatorRequiresValue && isEmptyValue(filter.values)) {
return VALUE_REQUIRED;
}
if (
filter.filter_operator === 'days_before' &&
(parseInt(filter.values, 10) <= 0 || parseInt(filter.values, 10) >= 999)
) {
return VALUE_MUST_BE_BETWEEN_1_AND_998;
}
return null;
};
// ------------------------------------------------------------------
// ---------------------- Automation Validation ---------------------
// ------------------------------------------------------------------
/**
* Validates the basic fields of an automation object.
*
* @param {Object} automation - The automation object to validate.
* @returns {Object} An object containing any validation errors.
*/
const validateBasicFields = automation => {
const errors = {};
const requiredFields = ['name', 'description', 'event_name'];
requiredFields.forEach(field => {
if (!automation[field]) {
errors[field] = `${
field.charAt(0).toUpperCase() + field.slice(1)
} is required`;
}
});
return errors;
};
/**
* Validates the conditions of an automation object.
*
* @param {Array} conditions - The conditions to validate.
* @returns {Object} An object containing any validation errors.
*/
export const validateConditions = conditions => {
const errors = {};
if (!conditions || conditions.length === 0) {
errors.conditions = ATLEAST_ONE_CONDITION_REQUIRED;
return errors;
}
conditions.forEach((condition, index) => {
const error = validateSingleFilter(condition);
if (error) {
errors[`condition_${index}`] = error;
}
});
return errors;
};
/**
* Validates a single action of an automation object.
*
* @param {Object} action - The action to validate.
* @returns {string|null} An error message if validation fails, or null if validation passes.
*/
const validateSingleAction = action => {
const noParamActions = [
'mute_conversation',
'snooze_conversation',
'resolve_conversation',
'remove_assigned_team',
'open_conversation',
'pending_conversation',
];
if (
!noParamActions.includes(action.action_name) &&
(!action.action_params || action.action_params.length === 0)
) {
return ACTION_PARAMETERS_REQUIRED;
}
return null;
};
/**
* Validates the actions of an automation object.
*
* @param {Array} actions - The actions to validate.
* @returns {Object} An object containing any validation errors.
*/
export const validateActions = actions => {
if (!actions || actions.length === 0) {
return { actions: ATLEAST_ONE_ACTION_REQUIRED };
}
return actions.reduce((errors, action, index) => {
const error = validateSingleAction(action);
if (error) {
errors[`action_${index}`] = error;
}
return errors;
}, {});
};
/**
* Validates an automation object.
*
* @param {Object} automation - The automation object to validate.
* @param {string} automation.name - The name of the automation.
* @param {string} automation.description - The description of the automation.
* @param {string} automation.event_name - The name of the event that triggers the automation.
* @param {Array} automation.conditions - An array of condition objects for the automation.
* @param {string} automation.conditions[].filter_operator - The operator for the condition.
* @param {string|number} [automation.conditions[].values] - The value(s) for the condition.
* @param {Array} automation.actions - An array of action objects for the automation.
* @param {string} automation.actions[].action_name - The name of the action.
* @param {Array} [automation.actions[].action_params] - The parameters for the action.
*
* @returns {Object} An object containing any validation errors.
*/
export const validateAutomation = automation => {
const basicErrors = validateBasicFields(automation);
const conditionErrors = validateConditions(automation.conditions);
const actionErrors = validateActions(automation.actions);
return {
...basicErrors,
...conditionErrors,
...actionErrors,
};
};

View File

@@ -0,0 +1,79 @@
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants';
import { useCallsStore } from 'dashboard/stores/calls';
import types from 'dashboard/store/mutation-types';
export const TERMINAL_STATUSES = [
'completed',
'busy',
'failed',
'no-answer',
'canceled',
'missed',
'ended',
];
export const isInbound = direction => direction === 'inbound';
const isVoiceCallMessage = message => {
return CONTENT_TYPES.VOICE_CALL === message?.content_type;
};
const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
function extractCallData(message) {
const contentData = message?.content_attributes?.data || {};
return {
callSid: contentData.call_sid,
status: contentData.status,
callDirection: contentData.call_direction,
conversationId: message?.conversation_id,
senderId: message?.sender?.id,
};
}
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, callDirection, conversationId, senderId } =
extractCallData(message);
if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
const callsStore = useCallsStore();
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
const { callSid, status, callDirection, conversationId, senderId } =
extractCallData(message);
const callsStore = useCallsStore();
callsStore.handleCallStatusChanged({ callSid, status, conversationId });
const callInfo = { conversationId, callStatus: status };
commit(types.UPDATE_CONVERSATION_CALL_STATUS, callInfo);
commit(types.UPDATE_MESSAGE_CALL_STATUS, callInfo);
const isNewCall =
status === 'ringing' &&
!shouldSkipCall(callDirection, senderId, currentUserId);
if (isNewCall) {
callsStore.addCall({
callSid,
conversationId,
callDirection,
senderId,
});
}
}