Merge branch 'develop' into feat/ui-lib

This commit is contained in:
Muhsin Keloth
2025-08-14 13:57:46 +05:30
committed by GitHub
1656 changed files with 51594 additions and 14096 deletions
@@ -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',
@@ -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];
@@ -128,6 +127,8 @@ export const getConditionOptions = ({
statusFilterOptions,
teams,
type,
priorityOptions,
messageTypeOptions,
}) => {
if (isCustomAttributeCheckbox(customAttributes, type)) {
return booleanFilterOptions;
@@ -147,8 +148,8 @@ 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,
};
return conditionFilterMaps[type];
@@ -168,8 +169,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,16 @@ 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();
};
@@ -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,
}),
};
/**
+2 -2
View File
@@ -139,14 +139,14 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
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',
@@ -4,6 +4,7 @@ import {
convertToAttributeSlug,
convertToCategorySlug,
convertToPortalSlug,
sanitizeVariableSearchKey,
} from '../commons';
describe('#getTypingUsersText', () => {
@@ -107,3 +108,37 @@ 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('');
});
});
@@ -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,368 @@
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',
});
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,91 @@
// 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();
}
// 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 (