Merge branch 'develop' into fix/message-contains
This commit is contained in:
@@ -6,6 +6,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
||||
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',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsBrowser } from '@june-so/analytics-next';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
let [analytics] = await AnalyticsBrowser.load({
|
||||
writeKey: this.analyticsToken,
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
});
|
||||
this.analytics = analytics;
|
||||
this.analytics = posthog;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +45,7 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
@@ -55,7 +56,7 @@ export class AnalyticsHelper {
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group(currentAccount.id, this.user.id, {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
}
|
||||
@@ -71,12 +72,7 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.track({
|
||||
userId: this.user.id,
|
||||
event: eventName,
|
||||
properties,
|
||||
});
|
||||
this.analytics.capture(eventName, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +85,9 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.page(params);
|
||||
this.analytics.capture('$pageview', params);
|
||||
}
|
||||
}
|
||||
|
||||
// This object is shared across, the init is called in app/javascript/packs/application.js
|
||||
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
|
||||
export default new AnalyticsHelper(window.analyticsConfig);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('@june-so/analytics-next', () => ({
|
||||
AnalyticsBrowser: {
|
||||
load: () => [
|
||||
{
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
page: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
],
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,12 +22,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize the analytics browser with the correct token', async () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize the analytics browser if token is not provided', async () => {
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -43,36 +39,36 @@ describe('AnalyticsHelper', () => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser with correct arguments', () => {
|
||||
it('should call identify on posthog with correct arguments', () => {
|
||||
analyticsHelper.identify({
|
||||
id: '123',
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '1',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 1,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
{
|
||||
userId: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
}
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
});
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'1',
|
||||
{ name: 'Account 1' }
|
||||
);
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser without group', () => {
|
||||
it('should call identify on posthog without group', () => {
|
||||
analyticsHelper.identify({
|
||||
id: '123',
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '5',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 5,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
|
||||
@@ -87,29 +83,27 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: '123' };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with correct arguments', () => {
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: { prop1: 'value1', prop2: 'value2' },
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with default properties', () => {
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: {},
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call track on analytics browser if analytics is not initialized', () => {
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -118,19 +112,22 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { page: vi.fn() };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the analytics.page method with the correct arguments', () => {
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
|
||||
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@ import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnec
|
||||
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) {
|
||||
@@ -30,6 +33,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
'conversation.read': this.onConversationRead,
|
||||
'conversation.updated': this.onConversationUpdated,
|
||||
'account.cache_invalidated': this.onCacheInvalidate,
|
||||
'copilot.message.created': this.onCopilotMessageCreated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +56,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -185,6 +190,10 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
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 });
|
||||
|
||||
@@ -36,6 +36,7 @@ const translationKeys = {
|
||||
'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) {
|
||||
@@ -168,6 +169,11 @@ export function generateTranslationPayload(auditLogItem, agentList) {
|
||||
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,
|
||||
|
||||
@@ -5,11 +5,9 @@ import {
|
||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||
import {
|
||||
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_CONVERSATION_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
import filterQueryGenerator from './filterQueryGenerator';
|
||||
import actionQueryGenerator from './actionQueryGenerator';
|
||||
@@ -103,6 +101,7 @@ export const getActionOptions = ({
|
||||
slaPolicies,
|
||||
type,
|
||||
addNoneToListFn,
|
||||
priorityOptions,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: addNoneToListFn ? addNoneToListFn(agents) : agents,
|
||||
@@ -110,7 +109,7 @@ export const getActionOptions = ({
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
change_priority: PRIORITY_CONDITION_VALUES,
|
||||
change_priority: priorityOptions,
|
||||
add_sla: slaPolicies,
|
||||
};
|
||||
return actionsMap[type];
|
||||
@@ -125,9 +124,12 @@ export const getConditionOptions = ({
|
||||
customAttributes,
|
||||
inboxes,
|
||||
languages,
|
||||
labels,
|
||||
statusFilterOptions,
|
||||
teams,
|
||||
type,
|
||||
priorityOptions,
|
||||
messageTypeOptions,
|
||||
}) => {
|
||||
if (isCustomAttributeCheckbox(customAttributes, type)) {
|
||||
return booleanFilterOptions;
|
||||
@@ -147,8 +149,9 @@ export const getConditionOptions = ({
|
||||
browser_language: languages,
|
||||
conversation_language: languages,
|
||||
country_code: countries,
|
||||
message_type: MESSAGE_CONDITION_VALUES,
|
||||
priority: PRIORITY_CONDITION_VALUES,
|
||||
message_type: messageTypeOptions,
|
||||
priority: priorityOptions,
|
||||
labels: generateConditionOptions(labels, 'title'),
|
||||
};
|
||||
|
||||
return conditionFilterMaps[type];
|
||||
@@ -168,8 +171,11 @@ export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
}
|
||||
if (eventName === 'conversation_opened') {
|
||||
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||
if (
|
||||
eventName === 'conversation_opened' ||
|
||||
eventName === 'conversation_resolved'
|
||||
) {
|
||||
return DEFAULT_CONVERSATION_CONDITION;
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
};
|
||||
|
||||
@@ -83,3 +83,31 @@ export const convertToPortalSlug = text => {
|
||||
.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() || ''
|
||||
);
|
||||
};
|
||||
|
||||
@@ -301,17 +301,30 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
const createNode = (editorView, nodeType, content) => {
|
||||
const { state } = editorView;
|
||||
switch (nodeType) {
|
||||
case 'mention':
|
||||
return state.schema.nodes.mention.create({
|
||||
case 'mention': {
|
||||
const mentionType = content.type || 'user';
|
||||
const displayName = content.displayName || content.name;
|
||||
|
||||
const mentionNode = state.schema.nodes.mention.create({
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
userFullName: displayName,
|
||||
mentionType,
|
||||
});
|
||||
|
||||
return mentionNode;
|
||||
}
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
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;
|
||||
}
|
||||
@@ -348,6 +361,11 @@ const nodeCreators = {
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
tool: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'tool', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
billing: 'https://chwt.app/pricing',
|
||||
saml: 'https://chwt.app/hc/saml',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export const INBOX_TYPES = {
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
INSTAGRAM: 'Channel::Instagram',
|
||||
VOICE: 'Channel::Voice',
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
@@ -22,6 +23,7 @@ const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-fill',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-fill',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_FILL = 'i-ri-chat-1-fill';
|
||||
@@ -36,6 +38,7 @@ const INBOX_ICON_MAP_LINE = {
|
||||
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-line',
|
||||
[INBOX_TYPES.LINE]: 'i-ri-line-line',
|
||||
[INBOX_TYPES.INSTAGRAM]: 'i-ri-instagram-line',
|
||||
[INBOX_TYPES.VOICE]: 'i-ri-phone-line',
|
||||
};
|
||||
|
||||
const DEFAULT_ICON_LINE = 'i-ri-chat-1-line';
|
||||
@@ -47,6 +50,7 @@ export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
|
||||
case INBOX_TYPES.TWILIO:
|
||||
case INBOX_TYPES.WHATSAPP:
|
||||
case INBOX_TYPES.VOICE:
|
||||
return phoneNumber || '';
|
||||
|
||||
case INBOX_TYPES.EMAIL:
|
||||
@@ -85,6 +89,9 @@ export const getReadableInboxByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.LINE:
|
||||
return 'line';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'voice';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
@@ -124,19 +131,22 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
case INBOX_TYPES.INSTAGRAM:
|
||||
return 'brand-instagram';
|
||||
|
||||
case INBOX_TYPES.VOICE:
|
||||
return 'phone';
|
||||
|
||||
default:
|
||||
return 'chat';
|
||||
}
|
||||
};
|
||||
|
||||
export const getInboxIconByType = (type, phoneNumber, variant = 'fill') => {
|
||||
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 && phoneNumber?.startsWith('whatsapp')) {
|
||||
if (type === INBOX_TYPES.TWILIO && medium === 'whatsapp') {
|
||||
return iconMap[INBOX_TYPES.WHATSAPP];
|
||||
}
|
||||
|
||||
|
||||
@@ -25,32 +25,6 @@ export const getUserRole = (user, accountId) => {
|
||||
return currentAccount.role || 'agent';
|
||||
};
|
||||
|
||||
const isPermissionsPresentInRoute = route =>
|
||||
route.meta && route.meta.permissions;
|
||||
|
||||
export const buildPermissionsFromRouter = (routes = []) =>
|
||||
routes.reduce((acc, route) => {
|
||||
if (route.name) {
|
||||
if (!isPermissionsPresentInRoute(route)) {
|
||||
// eslint-disable-next-line
|
||||
console.error(route);
|
||||
throw new Error(
|
||||
"The route doesn't have the required permissions defined"
|
||||
);
|
||||
}
|
||||
acc[route.name] = route.meta.permissions;
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
acc = {
|
||||
...acc,
|
||||
...buildPermissionsFromRouter(route.children),
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Filters and transforms items based on user permissions.
|
||||
*
|
||||
|
||||
@@ -66,42 +66,6 @@ export const getArticleStatus = status => {
|
||||
}
|
||||
};
|
||||
|
||||
// Constants
|
||||
export const HELP_CENTER_MENU_ITEMS = [
|
||||
{
|
||||
label: 'Articles',
|
||||
icon: 'i-lucide-book',
|
||||
action: 'portals_articles_index',
|
||||
value: [
|
||||
'portals_articles_index',
|
||||
'portals_articles_new',
|
||||
'portals_articles_edit',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
icon: 'i-lucide-folder',
|
||||
action: 'portals_categories_index',
|
||||
value: [
|
||||
'portals_categories_index',
|
||||
'portals_categories_articles_index',
|
||||
'portals_categories_articles_edit',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Locales',
|
||||
icon: 'i-lucide-languages',
|
||||
action: 'portals_locales_index',
|
||||
value: ['portals_locales_index'],
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'i-lucide-settings',
|
||||
action: 'portals_settings_index',
|
||||
value: ['portals_settings_index'],
|
||||
},
|
||||
];
|
||||
|
||||
export const ARTICLE_STATUSES = {
|
||||
DRAFT: 'draft',
|
||||
PUBLISHED: 'published',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasValidAvatarUrl,
|
||||
timeStampAppendedURL,
|
||||
getHostNameFromURL,
|
||||
extractFilenameFromUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
convertToAttributeSlug,
|
||||
convertToCategorySlug,
|
||||
convertToPortalSlug,
|
||||
sanitizeVariableSearchKey,
|
||||
formatToTitleCase,
|
||||
} from '../commons';
|
||||
|
||||
describe('#getTypingUsersText', () => {
|
||||
@@ -107,3 +109,85 @@ describe('convertToPortalSlug', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('getContentNode', () => {
|
||||
{
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
mentionType: 'user',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
insertAtCursor,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
} from '../editorHelper';
|
||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||
import { EditorView } from '@chatwoot/prosemirror-schema';
|
||||
@@ -18,12 +19,28 @@ const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'paragraph+' },
|
||||
paragraph: {
|
||||
content: 'text*',
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
toDOM: () => ['p', 0], // Represents a paragraph as a <p> tag in the DOM.
|
||||
},
|
||||
text: {
|
||||
group: 'inline',
|
||||
toDOM: node => node.text, // Represents text as its actual string value.
|
||||
},
|
||||
mention: {
|
||||
attrs: {
|
||||
userId: { default: '' },
|
||||
userFullName: { default: '' },
|
||||
mentionType: { default: 'user' },
|
||||
},
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
toDOM: node => [
|
||||
'span',
|
||||
{ class: 'mention' },
|
||||
`@${node.attrs.userFullName}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,3 +456,173 @@ describe('setURLWithQueryAndSize', () => {
|
||||
expect(editorView.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let mockEditorView;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEditorView = {
|
||||
state: {
|
||||
schema: {
|
||||
nodes: {
|
||||
mention: {
|
||||
create: vi.fn(attrs => ({
|
||||
type: { name: 'mention' },
|
||||
attrs,
|
||||
})),
|
||||
},
|
||||
},
|
||||
text: vi.fn(content => ({ type: { name: 'text' }, text: content })),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('mention node creation', () => {
|
||||
it('creates a user mention node with correct attributes', () => {
|
||||
const userContent = {
|
||||
id: '123',
|
||||
name: 'John Doe',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', userContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '123',
|
||||
userFullName: 'John Doe',
|
||||
mentionType: 'user',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('creates a team mention node with correct attributes', () => {
|
||||
const teamContent = {
|
||||
id: '456',
|
||||
name: 'Support Team',
|
||||
type: 'team',
|
||||
};
|
||||
|
||||
const result = getContentNode(mockEditorView, 'mention', teamContent, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: {
|
||||
type: { name: 'mention' },
|
||||
attrs: {
|
||||
userId: '456',
|
||||
userFullName: 'Support Team',
|
||||
mentionType: 'team',
|
||||
},
|
||||
},
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to user mention type when type is not specified', () => {
|
||||
const contentWithoutType = {
|
||||
id: '789',
|
||||
name: 'Jane Smith',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutType, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '789',
|
||||
userFullName: 'Jane Smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses displayName over name when both are provided', () => {
|
||||
const contentWithDisplayName = {
|
||||
id: '101',
|
||||
name: 'john_doe',
|
||||
displayName: 'John Doe (Admin)',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '101',
|
||||
userFullName: 'John Doe (Admin)',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing displayName by falling back to name', () => {
|
||||
const contentWithoutDisplayName = {
|
||||
id: '102',
|
||||
name: 'jane_smith',
|
||||
type: 'user',
|
||||
};
|
||||
|
||||
getContentNode(mockEditorView, 'mention', contentWithoutDisplayName, {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(
|
||||
mockEditorView.state.schema.nodes.mention.create
|
||||
).toHaveBeenCalledWith({
|
||||
userId: '102',
|
||||
userFullName: 'jane_smith',
|
||||
mentionType: 'user',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsupported node types', () => {
|
||||
it('returns null node for unsupported type', () => {
|
||||
const result = getContentNode(mockEditorView, 'unsupported', 'content', {
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
node: null,
|
||||
from: 0,
|
||||
to: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,11 @@ import allLanguages from 'dashboard/components/widgets/conversation/advancedFilt
|
||||
|
||||
import allCountries from 'shared/constants/countries.js';
|
||||
|
||||
import {
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
|
||||
export const customAttributes = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -468,7 +473,7 @@ export const inboxes = [
|
||||
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.defer = true;\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 ',
|
||||
'\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',
|
||||
@@ -636,16 +641,16 @@ export const statusFilterOptions = [
|
||||
];
|
||||
export const languages = allLanguages;
|
||||
export const countries = allCountries;
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
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',
|
||||
|
||||
@@ -112,13 +112,13 @@ describe('#Inbox Helpers', () => {
|
||||
describe('Twilio cases', () => {
|
||||
describe('fill variant', () => {
|
||||
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
|
||||
).toBe('i-ri-whatsapp-fill');
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
||||
'i-ri-whatsapp-fill'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns SMS icon for regular Twilio number', () => {
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
||||
'i-ri-chat-1-fill'
|
||||
);
|
||||
});
|
||||
@@ -133,18 +133,14 @@ describe('#Inbox Helpers', () => {
|
||||
describe('line variant', () => {
|
||||
it('returns WhatsApp line icon for Twilio WhatsApp number', () => {
|
||||
expect(
|
||||
getInboxIconByType(
|
||||
INBOX_TYPES.TWILIO,
|
||||
'whatsapp:+1234567890',
|
||||
'line'
|
||||
)
|
||||
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp', 'line')
|
||||
).toBe('i-ri-whatsapp-line');
|
||||
});
|
||||
|
||||
it('returns SMS line icon for regular Twilio number', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890', 'line')
|
||||
).toBe('i-ri-chat-1-line');
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line')).toBe(
|
||||
'i-ri-chat-1-line'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
buildPermissionsFromRouter,
|
||||
getCurrentAccount,
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
@@ -43,70 +42,6 @@ describe('hasPermissions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPermissionsFromRouter', () => {
|
||||
it('returns a valid object when routes have permissions defined', () => {
|
||||
expect(
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: { permissions: ['agent_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'inbox',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'inbox_list',
|
||||
meta: { permissions: ['inbox_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'conversations',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: 'attachments',
|
||||
name: 'attachments_list',
|
||||
meta: { permissions: ['conversation_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
).toEqual({
|
||||
agent_list: ['agent_admin'],
|
||||
inbox_list: ['inbox_admin'],
|
||||
attachments_list: ['conversation_admin'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if a named routed does not have permissions defined', () => {
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterItemsByPermission', () => {
|
||||
const items = {
|
||||
item1: { name: 'Item 1', permissions: ['agent', 'administrator'] },
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -126,6 +126,7 @@ const validateSingleAction = action => {
|
||||
'snooze_conversation',
|
||||
'resolve_conversation',
|
||||
'remove_assigned_team',
|
||||
'open_conversation',
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user