diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue
index 591267c31..0d42d15cf 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/Index.vue
@@ -1,102 +1,191 @@
-
+
+
+ |
+ {{ thHeader }}
+ |
+
+
+
+
+
+
+
+
+ {{ bot.name }}
+
+ {{ $t('AGENT_BOTS.GLOBAL_BOT_BADGE') }}
+
+
+
+ {{ bot.description }}
+
+
+
+ |
+
+ {{ bot.outgoing_url || bot.bot_config?.webhook_url }}
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
index d83335d7e..d82d2e32e 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/agentBot.routes.js
@@ -1,9 +1,6 @@
import { FEATURE_FLAGS } from '../../../../featureFlags';
import Bot from './Index.vue';
-import CsmlEditBot from './csml/Edit.vue';
-import CsmlNewBot from './csml/New.vue';
import { frontendURL } from '../../../../helper/URLHelper';
-import SettingsContent from '../Wrapper.vue';
import SettingsWrapper from '../SettingsWrapper.vue';
export default {
@@ -26,36 +23,5 @@ export default {
},
],
},
- {
- path: frontendURL('accounts/:accountId/settings/agent-bots'),
- component: SettingsContent,
- props: () => {
- return {
- headerTitle: 'AGENT_BOTS.HEADER',
- icon: 'bot',
- showBackButton: true,
- };
- },
- children: [
- {
- path: 'csml/new',
- name: 'agent_bots_csml_new',
- component: CsmlNewBot,
- meta: {
- featureFlag: FEATURE_FLAGS.AGENT_BOTS,
- permissions: ['administrator'],
- },
- },
- {
- path: 'csml/:botId',
- name: 'agent_bots_csml_edit',
- component: CsmlEditBot,
- meta: {
- featureFlag: FEATURE_FLAGS.AGENT_BOTS,
- permissions: ['administrator'],
- },
- },
- ],
- },
],
};
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue
new file mode 100644
index 000000000..b64b1ef58
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotModal.vue
@@ -0,0 +1,251 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue
deleted file mode 100644
index 365e4948f..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotRow.vue
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- {{ agentBot.name }}
- ( )
-
-
-
-
- |
-
-
-
-
-
- |
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue
deleted file mode 100644
index b207e8637..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/AgentBotType.vue
+++ /dev/null
@@ -1,36 +0,0 @@
-
-
-
-
-
- {{ botTypeConfig[botType].label }}
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLBotEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLBotEditor.vue
deleted file mode 100644
index 9c962136d..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLBotEditor.vue
+++ /dev/null
@@ -1,99 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ $t('AGENT_BOTS.CSML_BOT_EDITOR.BOT_CONFIG.ERROR') }}
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLMonacoEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLMonacoEditor.vue
deleted file mode 100644
index 0f80e0e31..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/components/CSMLMonacoEditor.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/Edit.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/Edit.vue
deleted file mode 100644
index 3c909fdba..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/Edit.vue
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/New.vue b/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/New.vue
deleted file mode 100644
index 10868e58c..000000000
--- a/app/javascript/dashboard/routes/dashboard/settings/agentBots/csml/New.vue
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
index ceeb84ca4..c92d0b1c8 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/AddAutomationRule.vue
@@ -82,7 +82,6 @@ export default {
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
- automationRuleEvents: AUTOMATION_RULE_EVENTS,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
@@ -96,6 +95,12 @@ export default {
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
+ automationRuleEvents() {
+ return AUTOMATION_RULE_EVENTS.map(event => ({
+ ...event,
+ value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
+ }));
+ },
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
@@ -105,10 +110,14 @@ export default {
return false;
},
automationActionTypes() {
- const isSLAEnabled = this.isFeatureEnabled('sla');
- return isSLAEnabled
+ const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
- : AUTOMATION_ACTION_TYPES.filter(action => action.key !== 'add_sla');
+ : AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
+
+ return actionTypes.map(action => ({
+ ...action,
+ label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
+ }));
},
},
mounted() {
@@ -137,6 +146,26 @@ export default {
this.$emit('saveAutomation', automation, this.mode);
}
},
+ getTranslatedAttributes(type, event) {
+ return getAttributes(type, event).map(attribute => {
+ // Skip translation
+ // 1. If customAttributeType key is present then its rendering attributes from API
+ // 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
+ const skipTranslation =
+ attribute.customAttributeType ||
+ [
+ 'contact_custom_attribute',
+ 'conversation_custom_attribute',
+ ].includes(attribute.key);
+
+ return {
+ ...attribute,
+ name: skipTranslation
+ ? attribute.name
+ : this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
+ };
+ });
+ },
},
};
@@ -204,7 +233,7 @@ export default {
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
- getAttributes(automationTypes, automation.event_name)
+ getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue
index f2ec9d7c7..208fcf7ec 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/EditAutomationRule.vue
@@ -70,7 +70,6 @@ export default {
data() {
return {
automationRuleEvent: AUTOMATION_RULE_EVENTS[0].key,
- automationRuleEvents: AUTOMATION_RULE_EVENTS,
automationMutated: false,
show: true,
showDeleteConfirmationModal: false,
@@ -84,6 +83,12 @@ export default {
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
+ automationRuleEvents() {
+ return AUTOMATION_RULE_EVENTS.map(event => ({
+ ...event,
+ value: this.$t(`AUTOMATION.EVENTS.${event.value}`),
+ }));
+ },
hasAutomationMutated() {
if (
this.automation.conditions[0].values ||
@@ -93,10 +98,14 @@ export default {
return false;
},
automationActionTypes() {
- const isSLAEnabled = this.isFeatureEnabled('sla');
- return isSLAEnabled
+ const actionTypes = this.isFeatureEnabled('sla')
? AUTOMATION_ACTION_TYPES
- : AUTOMATION_ACTION_TYPES.filter(action => action.key !== 'add_sla');
+ : AUTOMATION_ACTION_TYPES.filter(({ key }) => key !== 'add_sla');
+
+ return actionTypes.map(action => ({
+ ...action,
+ label: this.$t(`AUTOMATION.ACTIONS.${action.label}`),
+ }));
},
},
mounted() {
@@ -127,6 +136,26 @@ export default {
this.$emit('saveAutomation', automation, this.mode);
}
},
+ getTranslatedAttributes(type, event) {
+ return getAttributes(type, event).map(attribute => {
+ // Skip translation
+ // 1. If customAttributeType key is present then its rendering attributes from API
+ // 2. If contact_custom_attribute or conversation_custom_attribute is present then its rendering section title
+ const skipTranslation =
+ attribute.customAttributeType ||
+ [
+ 'contact_custom_attribute',
+ 'conversation_custom_attribute',
+ ].includes(attribute.key);
+
+ return {
+ ...attribute,
+ name: skipTranslation
+ ? attribute.name
+ : this.$t(`AUTOMATION.ATTRIBUTES.${attribute.name}`),
+ };
+ });
+ },
},
};
@@ -187,7 +216,7 @@ export default {
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
- getAttributes(automationTypes, automation.event_name)
+ getTranslatedAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
diff --git a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
index c8d67745c..18468e3ad 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/automation/constants.js
@@ -10,43 +10,37 @@ export const AUTOMATIONS = {
conditions: [
{
key: 'message_type',
- name: 'Message Type',
- attributeI18nKey: 'MESSAGE_TYPE',
+ name: 'MESSAGE_TYPE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'content',
- name: 'Message Content',
- attributeI18nKey: 'MESSAGE_CONTAINS',
+ name: 'MESSAGE_CONTAINS',
inputType: 'comma_separated_plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'email',
- name: 'Email',
- attributeI18nKey: 'EMAIL',
+ name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'inbox_id',
- name: 'Inbox',
- attributeI18nKey: 'INBOX',
+ name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
- name: 'Conversation Language',
- attributeI18nKey: 'CONVERSATION_LANGUAGE',
+ name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'phone_number',
- name: 'Phone Number',
- attributeI18nKey: 'PHONE_NUMBER',
+ name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
@@ -54,64 +48,52 @@ export const AUTOMATIONS = {
actions: [
{
key: 'assign_agent',
- name: 'Assign to agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
- name: 'Assign a team',
- attributeI18nKey: 'ASSIGN_TEAM',
+ name: 'ASSIGN_TEAM',
},
{
key: 'add_label',
- name: 'Add a label',
- attributeI18nKey: 'ADD_LABEL',
+ name: 'ADD_LABEL',
},
{
key: 'remove_label',
- name: 'Remove a label',
- attributeI18nKey: 'REMOVE_LABEL',
+ name: 'REMOVE_LABEL',
},
{
key: 'send_email_to_team',
- name: 'Send an email to team',
- attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
+ name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
- name: 'Send a message',
- attributeI18nKey: 'SEND_MESSAGE',
+ name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
- name: 'Send an email transcript',
- attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
+ name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
- name: 'Mute conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
- name: 'Snooze conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'SNOOZE_CONVERSATION',
},
{
key: 'resolve_conversation',
- name: 'Resolve conversation',
- attributeI18nKey: 'RESOLVE_CONVERSATION',
+ name: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
- name: 'Send Webhook Event',
- attributeI18nKey: 'SEND_WEBHOOK_EVENT',
+ name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
- name: 'Send Attachment',
- attributeI18nKey: 'SEND_ATTACHMENT',
+ name: 'SEND_ATTACHMENT',
},
],
},
@@ -119,71 +101,61 @@ export const AUTOMATIONS = {
conditions: [
{
key: 'status',
- name: 'Status',
- attributeI18nKey: 'STATUS',
+ name: 'STATUS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'browser_language',
- name: 'Browser Language',
- attributeI18nKey: 'BROWSER_LANGUAGE',
+ name: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'mail_subject',
- name: 'Email Subject',
- attributeI18nKey: 'MAIL_SUBJECT',
+ name: 'MAIL_SUBJECT',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'country_code',
- name: 'Country',
- attributeI18nKey: 'COUNTRY_NAME',
+ name: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'phone_number',
- name: 'Phone Number',
- attributeI18nKey: 'PHONE_NUMBER',
+ name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'referer',
- name: 'Referrer Link',
- attributeI18nKey: 'REFERER_LINK',
+ name: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'email',
- name: 'Email',
- attributeI18nKey: 'EMAIL',
+ name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'inbox_id',
- name: 'Inbox',
- attributeI18nKey: 'INBOX',
+ name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
- name: 'Conversation Language',
- attributeI18nKey: 'CONVERSATION_LANGUAGE',
+ name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'priority',
- name: 'Priority',
- attributeI18nKey: 'PRIORITY',
+ name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
@@ -191,58 +163,47 @@ export const AUTOMATIONS = {
actions: [
{
key: 'assign_agent',
- name: 'Assign to agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
- name: 'Assign a team',
- attributeI18nKey: 'ASSIGN_TEAM',
+ name: 'ASSIGN_TEAM',
},
{
key: 'assign_agent',
- name: 'Assign an agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'send_email_to_team',
- name: 'Send an email to team',
- attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
+ name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
- name: 'Send a message',
- attributeI18nKey: 'SEND_MESSAGE',
+ name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
- name: 'Send an email transcript',
- attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
+ name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
- name: 'Mute conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
- name: 'Snooze conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'SNOOZE_CONVERSATION',
},
{
key: 'resolve_conversation',
- name: 'Resolve conversation',
- attributeI18nKey: 'RESOLVE_CONVERSATION',
+ name: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
- name: 'Send Webhook Event',
- attributeI18nKey: 'SEND_WEBHOOK_EVENT',
+ name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
- name: 'Send Attachment',
- attributeI18nKey: 'SEND_ATTACHMENT',
+ name: 'SEND_ATTACHMENT',
},
],
},
@@ -250,85 +211,73 @@ export const AUTOMATIONS = {
conditions: [
{
key: 'status',
- name: 'Status',
- attributeI18nKey: 'STATUS',
+ name: 'STATUS',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'browser_language',
- name: 'Browser Language',
- attributeI18nKey: 'BROWSER_LANGUAGE',
+ name: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'mail_subject',
- name: 'Email Subject',
- attributeI18nKey: 'MAIL_SUBJECT',
+ name: 'MAIL_SUBJECT',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'country_code',
- name: 'Country',
- attributeI18nKey: 'COUNTRY_NAME',
+ name: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'referer',
- name: 'Referrer Link',
- attributeI18nKey: 'REFERER_LINK',
+ name: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'phone_number',
- name: 'Phone Number',
- attributeI18nKey: 'PHONE_NUMBER',
+ name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'assignee_id',
- name: 'Assignee',
- attributeI18nKey: 'ASSIGNEE_NAME',
+ name: 'ASSIGNEE_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'team_id',
- name: 'Team',
- attributeI18nKey: 'TEAM_NAME',
+ name: 'TEAM_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'email',
- name: 'Email',
- attributeI18nKey: 'EMAIL',
+ name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'inbox_id',
- name: 'Inbox',
- attributeI18nKey: 'INBOX',
+ name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
- name: 'Conversation Language',
- attributeI18nKey: 'CONVERSATION_LANGUAGE',
+ name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'priority',
- name: 'Priority',
- attributeI18nKey: 'PRIORITY',
+ name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
@@ -336,58 +285,47 @@ export const AUTOMATIONS = {
actions: [
{
key: 'assign_agent',
- name: 'Assign to agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
- name: 'Assign a team',
- attributeI18nKey: 'ASSIGN_TEAM',
+ name: 'ASSIGN_TEAM',
},
{
key: 'assign_agent',
- name: 'Assign an agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'send_email_to_team',
- name: 'Send an email to team',
- attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
+ name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
- name: 'Send a message',
- attributeI18nKey: 'SEND_MESSAGE',
+ name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
- name: 'Send an email transcript',
- attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
+ name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
- name: 'Mute conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
- name: 'Snooze conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'SNOOZE_CONVERSATION',
},
{
key: 'resolve_conversation',
- name: 'Resolve conversation',
- attributeI18nKey: 'RESOLVE_CONVERSATION',
+ name: 'RESOLVE_CONVERSATION',
},
{
key: 'send_webhook_event',
- name: 'Send Webhook Event',
- attributeI18nKey: 'SEND_WEBHOOK_EVENT',
+ name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
- name: 'Send Attachment',
- attributeI18nKey: 'SEND_ATTACHMENT',
+ name: 'SEND_ATTACHMENT',
},
],
},
@@ -395,78 +333,67 @@ export const AUTOMATIONS = {
conditions: [
{
key: 'browser_language',
- name: 'Browser Language',
- attributeI18nKey: 'BROWSER_LANGUAGE',
+ name: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'email',
- name: 'Email',
- attributeI18nKey: 'EMAIL',
+ name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'mail_subject',
- name: 'Email Subject',
- attributeI18nKey: 'MAIL_SUBJECT',
+ name: 'MAIL_SUBJECT',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'country_code',
- name: 'Country',
- attributeI18nKey: 'COUNTRY_NAME',
+ name: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'referer',
- name: 'Referrer Link',
- attributeI18nKey: 'REFERER_LINK',
+ name: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
- name: 'Assignee',
- attributeI18nKey: 'ASSIGNEE_NAME',
+ name: 'ASSIGNEE_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'phone_number',
- name: 'Phone Number',
- attributeI18nKey: 'PHONE_NUMBER',
+ name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'team_id',
- name: 'Team',
- attributeI18nKey: 'TEAM_NAME',
+ name: 'TEAM_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'inbox_id',
- name: 'Inbox',
- attributeI18nKey: 'INBOX',
+ name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
- name: 'Conversation Language',
- attributeI18nKey: 'CONVERSATION_LANGUAGE',
+ name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'priority',
- name: 'Priority',
- attributeI18nKey: 'PRIORITY',
+ name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
@@ -474,53 +401,43 @@ export const AUTOMATIONS = {
actions: [
{
key: 'assign_agent',
- name: 'Assign to agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
- name: 'Assign a team',
- attributeI18nKey: 'ASSIGN_TEAM',
+ name: 'ASSIGN_TEAM',
},
{
key: 'assign_agent',
- name: 'Assign an agent',
- attributeI18nKey: 'ASSIGN_AGENT',
+ name: 'ASSIGN_AGENT',
},
{
key: 'send_email_to_team',
- name: 'Send an email to team',
- attributeI18nKey: 'SEND_EMAIL_TO_TEAM',
+ name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
- name: 'Send a message',
- attributeI18nKey: 'SEND_MESSAGE',
+ name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
- name: 'Send an email transcript',
- attributeI18nKey: 'SEND_EMAIL_TRANSCRIPT',
+ name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'mute_conversation',
- name: 'Mute conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'MUTE_CONVERSATION',
},
{
key: 'snooze_conversation',
- name: 'Snooze conversation',
- attributeI18nKey: 'MUTE_CONVERSATION',
+ name: 'SNOOZE_CONVERSATION',
},
{
key: 'send_webhook_event',
- name: 'Send Webhook Event',
- attributeI18nKey: 'SEND_WEBHOOK_EVENT',
+ name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
- name: 'Send Attachment',
- attributeI18nKey: 'SEND_ATTACHMENT',
+ name: 'SEND_ATTACHMENT',
},
],
},
@@ -529,91 +446,91 @@ export const AUTOMATIONS = {
export const AUTOMATION_RULE_EVENTS = [
{
key: 'conversation_created',
- value: 'Conversation Created',
+ value: 'CONVERSATION_CREATED',
},
{
key: 'conversation_updated',
- value: 'Conversation Updated',
+ value: 'CONVERSATION_UPDATED',
},
{
key: 'message_created',
- value: 'Message Created',
+ value: 'MESSAGE_CREATED',
},
{
key: 'conversation_opened',
- value: 'Conversation Opened',
+ value: 'CONVERSATION_OPENED',
},
];
export const AUTOMATION_ACTION_TYPES = [
{
key: 'assign_agent',
- label: 'Assign to agent',
+ label: 'ASSIGN_AGENT',
inputType: 'search_select',
},
{
key: 'assign_team',
- label: 'Assign a team',
+ label: 'ASSIGN_TEAM',
inputType: 'search_select',
},
{
key: 'add_label',
- label: 'Add a label',
+ label: 'ADD_LABEL',
inputType: 'multi_select',
},
{
key: 'remove_label',
- label: 'Remove a label',
+ label: 'REMOVE_LABEL',
inputType: 'multi_select',
},
{
key: 'send_email_to_team',
- label: 'Send an email to team',
+ label: 'SEND_EMAIL_TO_TEAM',
inputType: 'team_message',
},
{
key: 'send_email_transcript',
- label: 'Send an email transcript',
+ label: 'SEND_EMAIL_TRANSCRIPT',
inputType: 'email',
},
{
key: 'mute_conversation',
- label: 'Mute conversation',
+ label: 'MUTE_CONVERSATION',
inputType: null,
},
{
key: 'snooze_conversation',
- label: 'Snooze conversation',
+ label: 'SNOOZE_CONVERSATION',
inputType: null,
},
{
key: 'resolve_conversation',
- label: 'Resolve conversation',
+ label: 'RESOLVE_CONVERSATION',
inputType: null,
},
{
key: 'send_webhook_event',
- label: 'Send Webhook Event',
+ label: 'SEND_WEBHOOK_EVENT',
inputType: 'url',
},
{
key: 'send_attachment',
- label: 'Send Attachment',
+ label: 'SEND_ATTACHMENT',
inputType: 'attachment',
},
{
key: 'send_message',
- label: 'Send a message',
+ label: 'SEND_MESSAGE',
inputType: 'textarea',
},
{
key: 'change_priority',
- label: 'Change Priority',
+ label: 'CHANGE_PRIORITY',
inputType: 'search_select',
},
{
key: 'add_sla',
- label: 'Add SLA',
+ label: 'ADD_SLA',
inputType: 'search_select',
},
];
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
index b34c50c7f..7ea58e3e5 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue
@@ -9,6 +9,7 @@ import Sms from './channels/Sms.vue';
import Whatsapp from './channels/Whatsapp.vue';
import Line from './channels/Line.vue';
import Telegram from './channels/Telegram.vue';
+import Instagram from './channels/Instagram.vue';
const channelViewList = {
facebook: Facebook,
@@ -20,6 +21,7 @@ const channelViewList = {
whatsapp: Whatsapp,
line: Line,
telegram: Telegram,
+ instagram: Instagram,
};
export default defineComponent({
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
index 9e91f0b72..8482dabc2 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue
@@ -35,6 +35,7 @@ export default {
},
{ key: 'telegram', name: 'Telegram' },
{ key: 'line', name: 'Line' },
+ { key: 'instagram', name: 'Instagram' },
];
},
...mapGetters({
@@ -62,7 +63,7 @@ export default {
import EmptyState from '../../../../components/widgets/EmptyState.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
-
+import DuplicateInboxBanner from './channels/instagram/DuplicateInboxBanner.vue';
+import { INBOX_TYPES } from 'dashboard/helper/inbox';
export default {
components: {
EmptyState,
NextButton,
+ DuplicateInboxBanner,
},
computed: {
currentInbox() {
@@ -16,6 +18,20 @@ export default {
isATwilioInbox() {
return this.currentInbox.channel_type === 'Channel::TwilioSms';
},
+ // Check if a facebook inbox exists with the same instagram_id
+ hasDuplicateInstagramInbox() {
+ const instagramId = this.currentInbox.instagram_id;
+ const facebookInbox =
+ this.$store.getters['inboxes/getFacebookInboxByInstagramId'](
+ instagramId
+ );
+
+ return (
+ this.currentInbox.channel_type === INBOX_TYPES.INSTAGRAM &&
+ facebookInbox
+ );
+ },
+
isAEmailInbox() {
return this.currentInbox.channel_type === 'Channel::Email';
},
@@ -72,8 +88,12 @@ export default {
+
-
+
+
+
-
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue
index 8201fa14f..d67a9ea6a 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Facebook.vue
@@ -11,6 +11,7 @@ import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
+import NextButton from 'dashboard/components-next/button/Button.vue';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/vue';
@@ -19,6 +20,7 @@ export default {
components: {
LoadingState,
PageHeader,
+ NextButton,
},
mixins: [globalConfigMixin],
setup() {
@@ -207,7 +209,7 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue
new file mode 100644
index 000000000..83620b496
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Instagram.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
{{ errorStateMessage }}
+
+
+
+
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.CONNECT_YOUR_INSTAGRAM_PROFILE') }}
+
+
+ {{ $t('INBOX_MGMT.ADD.INSTAGRAM.HELP') }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue
index d4da535ae..70932b81c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Twitter.vue
@@ -1,8 +1,12 @@
+
+
+
+ {{ content }}
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue
new file mode 100644
index 000000000..003017d1d
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/instagram/Reauthorize.vue
@@ -0,0 +1,37 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
index 91cc2282e..b7b0a2c1d 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/components/ChannelName.vue
@@ -28,6 +28,7 @@ const i18nMap = {
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
+ 'Channel::Instagram': 'INSTAGRAM',
};
const twilioChannelName = () => {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
index 4478b7760..f704f4553 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/NewHook.vue
@@ -189,10 +189,4 @@ export default {
.formkit-actions {
@apply hidden;
}
-
-@media (prefers-color-scheme: dark) {
- .pre-chat-header-message .link {
- @apply text-woot-500 underline;
- }
-}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
index a808adf23..c974ef4d6 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroEditor.vue
@@ -21,7 +21,13 @@ const { getMacroDropdownValues } = useMacros();
const macro = ref(null);
const mode = ref('CREATE');
-const macroActionTypes = MACRO_ACTION_TYPES;
+
+const macroActionTypes = computed(() => {
+ return MACRO_ACTION_TYPES.map(type => ({
+ ...type,
+ label: t(`MACROS.ACTIONS.${type.label}`),
+ }));
+});
provide('macroActionTypes', macroActionTypes);
@@ -38,7 +44,7 @@ const formatMacro = macroData => {
const formattedActions = macroData.actions.map(action => {
let actionParams = [];
if (action.action_params.length) {
- const inputType = macroActionTypes.find(
+ const inputType = macroActionTypes.value.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue
index e1fc2d2e7..e010b270c 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/MacroNode.vue
@@ -42,7 +42,7 @@ const showActionInput = computed(() => {
actionData.value.action_name === 'send_message'
)
return false;
- const type = macroActionTypes.find(
+ const type = macroActionTypes.value.find(
action => action.key === actionData.value.action_name
).inputType;
return !!type;
diff --git a/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js b/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js
index 111a3632d..e8ee4fdef 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/macros/constants.js
@@ -1,67 +1,72 @@
export const MACRO_ACTION_TYPES = [
{
key: 'assign_team',
- label: 'Assign a team',
+ label: 'ASSIGN_TEAM',
inputType: 'search_select',
},
{
key: 'assign_agent',
- label: 'Assign an agent',
+ label: 'ASSIGN_AGENT',
inputType: 'search_select',
},
{
key: 'add_label',
- label: 'Add a label',
+ label: 'ADD_LABEL',
inputType: 'multi_select',
},
{
key: 'remove_label',
- label: 'Remove a label',
+ label: 'REMOVE_LABEL',
inputType: 'multi_select',
},
{
key: 'remove_assigned_team',
- label: 'Remove Assigned Team',
+ label: 'REMOVE_ASSIGNED_TEAM',
inputType: null,
},
{
key: 'send_email_transcript',
- label: 'Send an email transcript',
+ label: 'SEND_EMAIL_TRANSCRIPT',
inputType: 'email',
},
{
key: 'mute_conversation',
- label: 'Mute conversation',
+ label: 'MUTE_CONVERSATION',
inputType: null,
},
{
key: 'snooze_conversation',
- label: 'Snooze conversation',
+ label: 'SNOOZE_CONVERSATION',
inputType: null,
},
{
key: 'resolve_conversation',
- label: 'Resolve conversation',
+ label: 'RESOLVE_CONVERSATION',
inputType: null,
},
{
key: 'send_attachment',
- label: 'Send Attachment',
+ label: 'SEND_ATTACHMENT',
inputType: 'attachment',
},
{
key: 'send_message',
- label: 'Send a message',
+ label: 'SEND_MESSAGE',
inputType: 'textarea',
},
{
key: 'add_private_note',
- label: 'Add a private note',
+ label: 'ADD_PRIVATE_NOTE',
inputType: 'textarea',
},
{
key: 'change_priority',
- label: 'Change Priority',
+ label: 'CHANGE_PRIORITY',
inputType: 'search_select',
},
+ {
+ key: 'send_webhook_event',
+ label: 'SEND_WEBHOOK_EVENT',
+ inputType: 'url',
+ },
];
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue
index 772a6810a..11800a029 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue
@@ -98,6 +98,7 @@ export default {
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
index 858ceb4ad..0cade8635 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue
@@ -1,11 +1,10 @@
-
+
import SLAPopoverCard from 'dashboard/components/widgets/conversation/components/SLAPopoverCard.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
+
export default {
components: {
SLAPopoverCard,
+ NextButton,
},
props: {
slaEvents: {
@@ -34,13 +37,13 @@ export default {
class="flex items-center col-span-2 text-slate-11 justify-end"
>
-
- {{ $t('SLA_REPORTS.TABLE.VIEW_DETAILS') }}
-
+ />
import BaseEmptyState from './BaseEmptyState.vue';
+import NextButton from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['primaryAction']);
const primaryAction = () => emit('primaryAction');
@@ -10,13 +11,11 @@ const primaryAction = () => emit('primaryAction');
{{ $t('SLA.LIST.404') }}
-
- {{ $t('SLA.ADD_ACTION_LONG') }}
-
+ />
diff --git a/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue b/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue
new file mode 100644
index 000000000..c23d3e76b
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/upgrade/UpgradePage.vue
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('GENERAL_SETTINGS.UPGRADE') }}
+
+
+
+
+ {{ limitExceededMessage }}
+
+
+ {{ t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/constants.js b/app/javascript/dashboard/store/constants.js
new file mode 100644
index 000000000..898c775b5
--- /dev/null
+++ b/app/javascript/dashboard/store/constants.js
@@ -0,0 +1,5 @@
+export const STATUS = {
+ FAILED: 'failed',
+ FETCHING: 'fetching',
+ FINISHED: 'finished',
+};
diff --git a/app/javascript/dashboard/store/modules/accounts.js b/app/javascript/dashboard/store/modules/accounts.js
index fb2c8b89f..561695d28 100644
--- a/app/javascript/dashboard/store/modules/accounts.js
+++ b/app/javascript/dashboard/store/modules/accounts.js
@@ -73,6 +73,29 @@ export const actions = {
throw new Error(error);
}
},
+ delete: async ({ commit }, { id }) => {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
+ try {
+ await AccountAPI.delete(id);
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
+ } catch (error) {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
+ throw new Error(error);
+ }
+ },
+ toggleDeletion: async (
+ { commit },
+ { action_type } = { action_type: 'delete' }
+ ) => {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
+ try {
+ await EnterpriseAccountAPI.toggleDeletion(action_type);
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
+ } catch (error) {
+ commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
+ throw new Error(error);
+ }
+ },
create: async ({ commit }, accountInfo) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true });
try {
diff --git a/app/javascript/dashboard/store/modules/agentBots.js b/app/javascript/dashboard/store/modules/agentBots.js
index 57ecf9912..3e9931057 100644
--- a/app/javascript/dashboard/store/modules/agentBots.js
+++ b/app/javascript/dashboard/store/modules/agentBots.js
@@ -12,6 +12,7 @@ export const state = {
isCreating: false,
isDeleting: false,
isUpdating: false,
+ isUpdatingAvatar: false,
isFetchingAgentBot: false,
isSettingAgentBot: false,
isDisconnecting: false,
@@ -48,10 +49,23 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
}
},
- create: async ({ commit }, agentBotObj) => {
+
+ create: async ({ commit }, botData) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
try {
- const response = await AgentBotsAPI.create(agentBotObj);
+ // Create FormData for file upload
+ const formData = new FormData();
+ formData.append('name', botData.name);
+ formData.append('description', botData.description);
+ formData.append('bot_type', botData.bot_type || 'webhook');
+ formData.append('outgoing_url', botData.outgoing_url);
+
+ // Add avatar file if available
+ if (botData.avatar) {
+ formData.append('avatar', botData.avatar);
+ }
+
+ const response = await AgentBotsAPI.create(formData);
commit(types.ADD_AGENT_BOT, response.data);
return response.data;
} catch (error) {
@@ -61,10 +75,22 @@ export const actions = {
}
return null;
},
- update: async ({ commit }, { id, ...agentBotObj }) => {
+
+ update: async ({ commit }, { id, data }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
try {
- const response = await AgentBotsAPI.update(id, agentBotObj);
+ // Create FormData for file upload
+ const formData = new FormData();
+ formData.append('name', data.name);
+ formData.append('description', data.description);
+ formData.append('bot_type', data.bot_type || 'webhook');
+ formData.append('outgoing_url', data.outgoing_url);
+
+ if (data.avatar) {
+ formData.append('avatar', data.avatar);
+ }
+
+ const response = await AgentBotsAPI.update(id, formData);
commit(types.EDIT_AGENT_BOT, response.data);
} catch (error) {
throwErrorMessage(error);
@@ -72,6 +98,7 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
}
},
+
delete: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
try {
@@ -83,6 +110,20 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false });
}
},
+
+ deleteAgentBotAvatar: async ({ commit }, id) => {
+ commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: true });
+ try {
+ await AgentBotsAPI.deleteAgentBotAvatar(id);
+ // Update the thumbnail to empty string after deletion
+ commit(types.UPDATE_AGENT_BOT_AVATAR, { id, thumbnail: '' });
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdatingAvatar: false });
+ }
+ },
+
show: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: true });
try {
@@ -150,6 +191,12 @@ export const mutations = {
[inboxId]: agentBotId,
};
},
+ [types.UPDATE_AGENT_BOT_AVATAR]($state, { id, thumbnail }) {
+ const botIndex = $state.records.findIndex(bot => bot.id === id);
+ if (botIndex !== -1) {
+ $state.records[botIndex].thumbnail = thumbnail || '';
+ }
+ },
};
export default {
diff --git a/app/javascript/dashboard/store/modules/conversationStats.js b/app/javascript/dashboard/store/modules/conversationStats.js
index 35fc1c6ab..bae365f30 100644
--- a/app/javascript/dashboard/store/modules/conversationStats.js
+++ b/app/javascript/dashboard/store/modules/conversationStats.js
@@ -1,37 +1,39 @@
import types from '../mutation-types';
import ConversationApi from '../../api/inbox/conversation';
+import { debounce } from '@chatwoot/utils';
const state = {
mineCount: 0,
unAssignedCount: 0,
allCount: 0,
- updatedOn: null,
};
export const getters = {
getStats: $state => $state,
};
+// Create a debounced version of the actual API call function
+const fetchMetaData = async (commit, params) => {
+ try {
+ const response = await ConversationApi.meta(params);
+ const {
+ data: { meta },
+ } = response;
+ commit(types.SET_CONV_TAB_META, meta);
+ } catch (error) {
+ // ignore
+ }
+};
+
+const debouncedFetchMetaData = debounce(fetchMetaData, 500, false, 1000);
+const longDebouncedFetchMetaData = debounce(fetchMetaData, 500, false, 5000);
+
export const actions = {
get: async ({ commit, state: $state }, params) => {
- const currentTime = new Date();
- const lastUpdatedTime = new Date($state.updatedOn);
-
- // Skip large accounts from making too many requests
- if (currentTime - lastUpdatedTime < 10000 && $state.allCount > 100) {
- // eslint-disable-next-line no-console
- console.warn('Skipping conversation meta fetch');
- return;
- }
-
- try {
- const response = await ConversationApi.meta(params);
- const {
- data: { meta },
- } = response;
- commit(types.SET_CONV_TAB_META, meta);
- } catch (error) {
- // Ignore error
+ if ($state.allCount > 100) {
+ longDebouncedFetchMetaData(commit, params);
+ } else {
+ debouncedFetchMetaData(commit, params);
}
},
set({ commit }, meta) {
diff --git a/app/javascript/dashboard/store/modules/conversations/getters.js b/app/javascript/dashboard/store/modules/conversations/getters.js
index 9329ef5dc..f5b83e546 100644
--- a/app/javascript/dashboard/store/modules/conversations/getters.js
+++ b/app/javascript/dashboard/store/modules/conversations/getters.js
@@ -1,6 +1,11 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
-import { applyPageFilters, sortComparator } from './helpers';
+import { applyPageFilters, applyRoleFilter, sortComparator } from './helpers';
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
+import { matchesFilters } from './helpers/filterHelpers';
+import {
+ getUserPermissions,
+ getUserRole,
+} from '../../../helper/permissionsHelper';
import camelcaseKeys from 'camelcase-keys';
export const getSelectedChatConversation = ({
@@ -13,6 +18,15 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
+ getFilteredConversations: ({
+ allConversations,
+ chatSortFilter,
+ appliedFilters,
+ }) => {
+ return allConversations
+ .filter(conversation => matchesFilters(conversation, appliedFilters))
+ .sort((a, b) => sortComparator(a, b, chatSortFilter));
+ },
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
conversation => conversation.id === selectedChatId
@@ -67,10 +81,24 @@ const getters = {
return isUnAssigned && shouldFilter;
});
},
- getAllStatusChats: _state => activeFilters => {
+ getAllStatusChats: (_state, _, __, rootGetters) => activeFilters => {
+ const currentUser = rootGetters.getCurrentUser;
+ const currentUserId = rootGetters.getCurrentUser.id;
+ const currentAccountId = rootGetters.getCurrentAccountId;
+
+ const permissions = getUserPermissions(currentUser, currentAccountId);
+ const userRole = getUserRole(currentUser, currentAccountId);
+
return _state.allConversations.filter(conversation => {
const shouldFilter = applyPageFilters(conversation, activeFilters);
- return shouldFilter;
+ const allowedForRole = applyRoleFilter(
+ conversation,
+ userRole,
+ permissions,
+ currentUserId
+ );
+
+ return shouldFilter && allowedForRole;
});
},
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers.js b/app/javascript/dashboard/store/modules/conversations/helpers.js
index 0063c8cfc..ebbdcbe64 100644
--- a/app/javascript/dashboard/store/modules/conversations/helpers.js
+++ b/app/javascript/dashboard/store/modules/conversations/helpers.js
@@ -62,6 +62,51 @@ export const applyPageFilters = (conversation, filters) => {
return shouldFilter;
};
+/**
+ * Filters conversations based on user role and permissions
+ *
+ * @param {Object} conversation - The conversation object to check permissions for
+ * @param {string} role - The user's role (administrator, agent, etc.)
+ * @param {Array} permissions - List of permission strings the user has
+ * @param {number|string} currentUserId - The ID of the current user
+ * @returns {boolean} - Whether the user has permissions to access this conversation
+ */
+export const applyRoleFilter = (
+ conversation,
+ role,
+ permissions,
+ currentUserId
+) => {
+ // the role === "agent" check is typically not correct on it's own
+ // the backend handles this by checking the custom_role_id at the user model
+ // here however, the `getUserRole` returns "custom_role" if the id is present,
+ // so we can check the role === "agent" directly
+ if (['administrator', 'agent'].includes(role)) {
+ return true;
+ }
+
+ // Check for full conversation management permission
+ if (permissions.includes('conversation_manage')) {
+ return true;
+ }
+
+ const conversationAssignee = conversation.meta.assignee;
+ const isUnassigned = !conversationAssignee;
+ const isAssignedToUser = conversationAssignee?.id === currentUserId;
+
+ // Check unassigned management permission
+ if (permissions.includes('conversation_unassigned_manage')) {
+ return isUnassigned || isAssignedToUser;
+ }
+
+ // Check participating conversation management permission
+ if (permissions.includes('conversation_participating_manage')) {
+ return isAssignedToUser;
+ }
+
+ return false;
+};
+
const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
new file mode 100644
index 000000000..2591c2c01
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/filterHelpers.js
@@ -0,0 +1,356 @@
+/**
+ * Conversation Filter Helpers
+ * ---------------------------
+ * This file contains helper functions for filtering conversations in the frontend.
+ * The filtering logic is designed to align with the backend SQL behavior to ensure
+ * consistent results across the application.
+ *
+ * Key components:
+ * 1. getValueFromConversation: Retrieves values from conversation objects, handling
+ * both top-level properties and nested attributes.
+ * 2. matchesCondition: Evaluates a single filter condition against a value.
+ * 3. matchesFilters: Evaluates a complete filter chain against a conversation.
+ * 4. buildJsonLogicRule: Transforms evaluated filters into a JSON Logic frule that
+ * respects SQL-like operator precedence.
+ *
+ * Filter Structure:
+ * -----------------
+ * Each filter has the following structure:
+ * {
+ * attributeKey: 'status', // The attribute to filter on
+ * filterOperator: 'equal_to', // The operator to use (equal_to, contains, etc.)
+ * values: ['open'], // The values to compare against
+ * queryOperator: 'and' // How this filter connects to the next one (and/or)
+ * }
+ *
+ * Operator Precedence:
+ * --------------------
+ * The filter evaluation respects SQL-like operator precedence using JSON Logic:
+ * https://www.postgresql.org/docs/17/sql-syntax-lexical.html#SQL-PRECEDENCE
+ * 1. First evaluates individual conditions
+ * 2. Then applies AND operators (groups consecutive AND conditions)
+ * 3. Finally applies OR operators (connects AND groups with OR operations)
+ *
+ * This means that a filter chain like "A AND B OR C" is evaluated as "(A AND B) OR C",
+ * and "A OR B AND C" is evaluated as "A OR (B AND C)".
+ *
+ * The implementation uses json-logic-js to apply these rules. The JsonLogic format is designed
+ * to allow you to share rules (logic) between front-end and back-end code
+ * Here we use json-logic-js to transform filter conditions into a nested JSON Logic structure that preserves proper
+ * operator precedence, effectively mimicking SQL-like operator precedence.
+ *
+ * Conversation Object Structure:
+ * -----------------------------
+ * The conversation object can have:
+ * 1. Top-level properties (status, priority, display_id, etc.)
+ * 2. Nested properties in additional_attributes (browser_language, referer, etc.)
+ * 3. Nested properties in custom_attributes (conversation_type, etc.)
+ */
+import jsonLogic from 'json-logic-js';
+
+/**
+ * Gets a value from a conversation based on the attribute key
+ * @param {Object} conversation - The conversation object
+ * @param {String} attributeKey - The attribute key to get the value for
+ * @returns {*} - The value of the attribute
+ *
+ * This function handles various attribute locations:
+ * 1. Direct properties on the conversation object (status, priority, etc.)
+ * 2. Properties in conversation.additional_attributes (browser_language, referer, etc.)
+ * 3. Properties in conversation.custom_attributes (conversation_type, etc.)
+ */
+const getValueFromConversation = (conversation, attributeKey) => {
+ switch (attributeKey) {
+ case 'status':
+ case 'priority':
+ case 'display_id':
+ case 'labels':
+ case 'created_at':
+ case 'last_activity_at':
+ return conversation[attributeKey];
+ case 'assignee_id':
+ return conversation.meta?.assignee?.id;
+ case 'inbox_id':
+ return conversation.inbox_id;
+ case 'team_id':
+ return conversation.meta?.team?.id;
+ case 'browser_language':
+ case 'country_code':
+ case 'referer':
+ return conversation.additional_attributes?.[attributeKey];
+ default:
+ // Check if it's a custom attribute
+ if (
+ conversation.custom_attributes &&
+ conversation.custom_attributes[attributeKey]
+ ) {
+ return conversation.custom_attributes[attributeKey];
+ }
+ return null;
+ }
+};
+
+/**
+ * Resolves the value from an input candidate
+ * @param {*} candidate - The input value to resolve
+ * @returns {*} - If the candidate is an object with an id property, returns the id;
+ * otherwise returns the candidate unchanged
+ *
+ * This helper function is used to normalize values, particularly when dealing with
+ * objects that represent entities like users, teams, or inboxes where we want to
+ * compare by ID rather than by the whole object.
+ */
+const resolveValue = candidate => {
+ if (
+ typeof candidate === 'object' &&
+ candidate !== null &&
+ 'id' in candidate
+ ) {
+ return candidate.id;
+ }
+
+ return candidate;
+};
+
+/**
+ * Checks if two values are equal in the context of filtering
+ * @param {*} filterValue - The filterValue value
+ * @param {*} conversationValue - The conversationValue value
+ * @returns {Boolean} - Returns true if the values are considered equal according to filtering rules
+ *
+ * This function handles various equality scenarios:
+ * 1. When both values are arrays: checks if all items in filterValue exist in conversationValue
+ * 2. When filterValue is an array but conversationValue is not: checks if conversationValue is included in filterValue
+ * 3. Otherwise: performs strict equality comparison
+ */
+const equalTo = (filterValue, conversationValue) => {
+ if (Array.isArray(filterValue)) {
+ if (filterValue.includes('all')) return true;
+ if (filterValue === 'all') return true;
+
+ if (Array.isArray(conversationValue)) {
+ // For array values like labels, check if any of the filter values exist in the array
+ return filterValue.every(val => conversationValue.includes(val));
+ }
+
+ if (!Array.isArray(conversationValue)) {
+ return filterValue.includes(conversationValue);
+ }
+ }
+
+ return conversationValue === filterValue;
+};
+
+/**
+ * Checks if the filterValue value is contained within the conversationValue value
+ * @param {*} filterValue - The value to look for
+ * @param {*} conversationValue - The value to search within
+ * @returns {Boolean} - Returns true if filterValue is contained within conversationValue
+ *
+ * This function performs case-insensitive string containment checks.
+ * It only works with string values and returns false for non-string types.
+ */
+const contains = (filterValue, conversationValue) => {
+ if (typeof conversationValue === 'string') {
+ return conversationValue.toLowerCase().includes(filterValue.toLowerCase());
+ }
+ return false;
+};
+
+/**
+ * Checks if a value matches a filter condition
+ * @param {*} conversationValue - The value to check
+ * @param {Object} filter - The filter condition
+ * @returns {Boolean} - Returns true if the value matches the filter
+ */
+const matchesCondition = (conversationValue, filter) => {
+ const { filter_operator: filterOperator, values } = filter;
+
+ // Handle null/undefined values
+ if (conversationValue === null || conversationValue === undefined) {
+ return filterOperator === 'is_not_present';
+ }
+
+ const filterValue = Array.isArray(values)
+ ? values.map(resolveValue)
+ : resolveValue(values);
+
+ switch (filterOperator) {
+ case 'equal_to':
+ return equalTo(filterValue, conversationValue);
+
+ case 'not_equal_to':
+ return !equalTo(filterValue, conversationValue);
+
+ case 'contains':
+ return contains(filterValue, conversationValue);
+
+ case 'does_not_contain':
+ return !contains(filterValue, conversationValue);
+
+ case 'is_present':
+ return true; // We already handled null/undefined above
+
+ case 'is_not_present':
+ return false; // We already handled null/undefined above
+
+ case 'is_greater_than':
+ return new Date(conversationValue) > new Date(filterValue);
+
+ case 'is_less_than':
+ return new Date(conversationValue) < new Date(filterValue);
+
+ case 'days_before': {
+ const today = new Date();
+ const daysInMilliseconds = filterValue * 24 * 60 * 60 * 1000;
+ const targetDate = new Date(today.getTime() - daysInMilliseconds);
+ return conversationValue < targetDate.getTime();
+ }
+
+ default:
+ return false;
+ }
+};
+
+/**
+ * Converts an array of evaluated filters into a JSON Logic rule
+ * that respects SQL-like operator precedence (AND before OR)
+ *
+ * This function transforms the linear sequence of filter results and operators
+ * into a nested JSON Logic structure that correctly implements SQL-like precedence:
+ * - AND operators are evaluated before OR operators
+ * - Consecutive AND conditions are grouped together
+ * - These AND groups are then connected with OR operators
+ *
+ * For example:
+ * - "A AND B AND C" becomes { "and": [A, B, C] }
+ * - "A OR B OR C" becomes { "or": [A, B, C] }
+ * - "A AND B OR C" becomes { "or": [{ "and": [A, B] }, C] }
+ * - "A OR B AND C" becomes { "or": [A, { "and": [B, C] }] }
+ *
+ * FILTER CHAIN: A --AND--> B --OR--> C --AND--> D --AND--> E --OR--> F
+ * | | | | | |
+ * v v v v v v
+ * EVALUATED: true false true false true false
+ * \ / \ \ / /
+ * \ / \ \ / /
+ * \ / \ \ / /
+ * \ / \ \ / /
+ * \ / \ \ / /
+ * AND GROUPS: [true,false] [true,false,true] [false]
+ * | | |
+ * v v v
+ * JSON LOGIC: {"and":[true,false]} {"and":[true,false,true]} false
+ * \ | /
+ * \ | /
+ * \ | /
+ * \ | /
+ * \ | /
+ * FINAL RULE: {"or":[{"and":[true,false]},{"and":[true,false,true]},false]}
+ *
+ * {
+ * "or": [
+ * { "and": [true, false] },
+ * { "and": [true, false, true] },
+ * { "and": [false] }
+ * ]
+ * }
+ * @param {Array} evaluatedFilters - Array of evaluated filter conditions with results and operators
+ * @returns {Object} - JSON Logic rule
+ */
+const buildJsonLogicRule = evaluatedFilters => {
+ // Step 1: Group consecutive AND conditions into logical units
+ // This implements the higher precedence of AND over OR
+ const andGroups = [];
+ let currentAndGroup = [evaluatedFilters[0].result];
+
+ for (let i = 0; i < evaluatedFilters.length - 1; i += 1) {
+ if (evaluatedFilters[i].operator === 'and') {
+ // When we see an AND operator, we add the next filter to the current AND group
+ // This builds up chains of AND conditions that will be evaluated together
+ currentAndGroup.push(evaluatedFilters[i + 1].result);
+ } else {
+ // When we see an OR operator, it marks the boundary between AND groups
+ // We finalize the current AND group and start a new one
+
+ // If the AND group has only one item, don't wrap it in an "and" operator
+ // Otherwise, create a proper "and" JSON Logic expression
+ andGroups.push(
+ currentAndGroup.length === 1
+ ? currentAndGroup[0] // Single item doesn't need an "and" wrapper
+ : { and: currentAndGroup } // Multiple items need to be AND-ed together
+ );
+
+ // Start a new AND group with the next filter's result
+ currentAndGroup = [evaluatedFilters[i + 1].result];
+ }
+ }
+
+ // Step 2: Add the final AND group that wasn't followed by an OR
+ if (currentAndGroup.length > 0) {
+ andGroups.push(
+ currentAndGroup.length === 1
+ ? currentAndGroup[0] // Single item doesn't need an "and" wrapper
+ : { and: currentAndGroup } // Multiple items need to be AND-ed together
+ );
+ }
+
+ // Step 3: Combine all AND groups with OR operators
+ // If we have multiple AND groups, they are separated by OR operators
+ // in the original filter chain, so we combine them with an "or" operation
+ if (andGroups.length > 1) {
+ return { or: andGroups };
+ }
+
+ // If there's only one AND group (which might be a single condition
+ // or multiple AND-ed conditions), just return it directly
+ return andGroups[0];
+};
+
+/**
+ * Evaluates each filter against the conversation and prepares the results array
+ * @param {Object} conversation - The conversation to evaluate
+ * @param {Array} filters - Filters to apply
+ * @returns {Array} - Array of evaluated filter results with operators
+ */
+const evaluateFilters = (conversation, filters) => {
+ return filters.map((filter, index) => {
+ const value = getValueFromConversation(conversation, filter.attribute_key);
+ const result = matchesCondition(value, filter);
+
+ // This part determines the logical operator that connects this filter to the next one:
+ // - If this is not the last filter (index < filters.length - 1), use the filter's query_operator
+ // or default to 'and' if query_operator is not specified
+ // - If this is the last filter, set operator to null since there's no next filter to connect to
+ const isLastFilter = index === filters.length - 1;
+ const operator = isLastFilter ? null : filter.query_operator || 'and';
+
+ return { result, operator };
+ });
+};
+
+/**
+ * Checks if a conversation matches the given filters
+ * @param {Object} conversation - The conversation object to check
+ * @param {Array} filters - Array of filter conditions
+ * @returns {Boolean} - Returns true if conversation matches filters, false otherwise
+ */
+export const matchesFilters = (conversation, filters) => {
+ // If no filters, return true
+ if (!filters || filters.length === 0) {
+ return true;
+ }
+
+ // Handle single filter case
+ if (filters.length === 1) {
+ const value = getValueFromConversation(
+ conversation,
+ filters[0].attribute_key
+ );
+ return matchesCondition(value, filters[0]);
+ }
+
+ // Evaluate all conditions and prepare for jsonLogic
+ const evaluatedFilters = evaluateFilters(conversation, filters);
+ return jsonLogic.apply(buildJsonLogicRule(evaluatedFilters));
+};
diff --git a/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
new file mode 100644
index 000000000..6d709f085
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversations/helpers/specs/filterHelpers.spec.js
@@ -0,0 +1,1302 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { matchesFilters } from '../filterHelpers';
+
+// SAMPLE PAYLOAD
+//
+// {
+// attribute_key: 'status',
+// attribute_model: 'standard',
+// filter_operator: 'equal_to',
+// values: [
+// {
+// id: 'open',
+// name: 'Open',
+// },
+// {
+// id: 'resolved',
+// name: 'Resolved',
+// },
+// {
+// id: 'pending',
+// name: 'Pending',
+// },
+// {
+// id: 'snoozed',
+// name: 'Snoozed',
+// },
+// {
+// id: 'all',
+// name: 'All',
+// },
+// ],
+// query_operator: 'and',
+// custom_attribute_type: '',
+// },
+// {
+// attribute_key: 'priority',
+// filter_operator: 'equal_to',
+// values: [
+// {
+// id: 'low',
+// name: 'Low',
+// },
+// {
+// id: 'medium',
+// name: 'Medium',
+// },
+// {
+// id: 'high',
+// name: 'High',
+// },
+// ],
+// query_operator: 'and',
+// },
+// {
+// attribute_key: 'assignee_id',
+// filter_operator: 'equal_to',
+// values: {
+// id: 12345,
+// name: 'Agent Name',
+// },
+// query_operator: 'and',
+// },
+// {
+// attribute_key: 'inbox_id',
+// filter_operator: 'equal_to',
+// values: {
+// id: 37,
+// name: 'Support Inbox',
+// },
+// query_operator: 'and',
+// },
+// {
+// attribute_key: 'team_id',
+// filter_operator: 'equal_to',
+// values: {
+// id: 220,
+// name: 'support-team',
+// },
+// query_operator: 'and',
+// },
+// {
+// attribute_key: 'created_at',
+// filter_operator: 'is_greater_than',
+// values: '2023-01-20',
+// query_operator: 'and',
+// },
+// {
+// attribute_key: 'last_activity_at',
+// filter_operator: 'days_before',
+// values: '998',
+// query_operator: 'and',
+// },
+
+describe('filterHelpers', () => {
+ describe('#matchesFilters', () => {
+ it('returns true by default when no filters are provided', () => {
+ const conversation = {};
+ const filters = [];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Standard attribute tests - status
+ it('should match conversation with equal_to operator for status', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with equal_to operator for status "all"', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'all', name: 'all' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with not_equal_to operator for status "all"', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'not_equal_to',
+ values: [{ id: 'all', name: 'all' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should not match conversation with not_equal_to operator for status', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'not_equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Standard attribute tests - assignee_id
+ it('should match conversation with equal_to operator for assignee_id', () => {
+ const conversation = { meta: { assignee: { id: 1 } } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'equal_to',
+ values: { id: 1, name: 'John Doe' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with not_equal_to operator for assignee_id', () => {
+ const conversation = { meta: { assignee: { id: 1 } } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'not_equal_to',
+ values: { id: 2, name: 'Jane Smith' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with is_present operator for assignee_id', () => {
+ const conversation = { meta: { assignee: { id: 1 } } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'is_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with is_not_present operator for assignee_id', () => {
+ const conversation = { meta: { assignee: null } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'is_not_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with is_present operator when assignee is null', () => {
+ const conversation = { meta: { assignee: null } };
+ const filters = [
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'is_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Standard attribute tests - priority
+ it('should match conversation with equal_to operator for priority', () => {
+ const conversation = { priority: 'urgent' };
+ const filters = [
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with not_equal_to operator for priority', () => {
+ const conversation = { priority: 'urgent' };
+ const filters = [
+ {
+ attribute_key: 'priority',
+ filter_operator: 'not_equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Text search tests - display_id
+ it('should match conversation with equal_to operator for display_id', () => {
+ const conversation = { display_id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '12345',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with contains operator for display_id', () => {
+ const conversation = { display_id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'contains',
+ values: '234',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with does_not_contain operator for display_id', () => {
+ const conversation = { display_id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'does_not_contain',
+ values: '234',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with does_not_contain operator when value is not present', () => {
+ const conversation = { display_id: '12345' };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'does_not_contain',
+ values: '678',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Array/List tests - labels
+ it('should match conversation with equal_to operator for labels', () => {
+ const conversation = { labels: ['support', 'urgent', 'new'] };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with equal_to operator for labels when value is not present', () => {
+ const conversation = { labels: ['support', 'urgent', 'new'] };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'equal_to',
+ values: [{ id: 'billing', name: 'Billing' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with not_equal_to operator for labels when value is not present', () => {
+ const conversation = { labels: ['support', 'urgent', 'new'] };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'not_equal_to',
+ values: [{ id: 'billing', name: 'Billing' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with is_present operator for labels', () => {
+ const conversation = { labels: ['support', 'urgent', 'new'] };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'is_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with is_not_present operator for labels when labels is null', () => {
+ const conversation = { labels: null };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'is_not_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with is_not_present operator for labels when labels is undefined', () => {
+ const conversation = {};
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'is_not_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Additional attributes tests
+ it('should match conversation with equal_to operator for browser_language', () => {
+ const conversation = {
+ additional_attributes: { browser_language: 'en-US' },
+ };
+ const filters = [
+ {
+ attribute_key: 'browser_language',
+ filter_operator: 'equal_to',
+ values: 'en-US',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with contains operator for referer', () => {
+ const conversation = {
+ additional_attributes: { referer: 'https://www.chatwoot.com/pricing' },
+ };
+ const filters = [
+ {
+ attribute_key: 'referer',
+ filter_operator: 'contains',
+ values: 'chatwoot',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with does_not_contain operator for referer', () => {
+ const conversation = {
+ additional_attributes: { referer: 'https://www.chatwoot.com/pricing' },
+ };
+ const filters = [
+ {
+ attribute_key: 'referer',
+ filter_operator: 'does_not_contain',
+ values: 'chatwoot',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Date tests
+ it('should match conversation with is_greater_than operator for created_at', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with is_greater_than operator for created_at when date is earlier', () => {
+ const conversation = { created_at: 1647691200000 }; // March 19, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-20',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with is_less_than operator for created_at', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_less_than',
+ values: '2022-03-21', // March 21, 2022
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ describe('days_before operator', () => {
+ beforeEach(() => {
+ // Set the date to March 25, 2022
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2022, 2, 25));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('should match conversation with days_before operator for created_at', () => {
+ const conversation = { created_at: 1647777600000 }; // March 20, 2022
+ const filters = [
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'days_before',
+ values: '3', // 3 days before March 25 = March 22
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+ });
+
+ // Multiple filters tests
+ it('should match conversation with multiple filters combined with AND operator', () => {
+ const conversation = {
+ status: 'open',
+ priority: 'urgent',
+ meta: { assignee: { id: 1 } },
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'equal_to',
+ values: {
+ id: 1,
+ name: 'Agent',
+ },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation when one filter in AND chain does not match', () => {
+ const conversation = {
+ status: 'open',
+ priority: 'urgent',
+ meta: { assignee: { id: 1 } },
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'low', name: 'Low' }], // This doesn't match
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'assignee_id',
+ filter_operator: 'equal_to',
+ values: {
+ id: 1,
+ name: 'Agent',
+ },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with multiple filters combined with OR operator', () => {
+ const conversation = {
+ status: 'open',
+ priority: 'low',
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'closed', name: 'Closed' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'low', name: 'Low' }],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation when one filter in OR chain matches', () => {
+ const conversation = {
+ status: 'open',
+ priority: 'low',
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }], // This matches
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }], // This doesn't match
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with multiple status and priority', () => {
+ const conversation = {
+ status: 'open',
+ priority: 'high',
+ meta: {
+ assignee: {
+ id: 83235,
+ },
+ },
+ };
+
+ const filters = [
+ {
+ values: ['open', 'resolved'],
+ attribute_key: 'status',
+ query_operator: 'and',
+ attribute_model: 'standard',
+ filter_operator: 'equal_to',
+ custom_attribute_type: '',
+ },
+ {
+ values: [83235],
+ attribute_key: 'assignee_id',
+ query_operator: 'and',
+ filter_operator: 'equal_to',
+ },
+ {
+ values: ['high', 'urgent'],
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ },
+ ];
+
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Nested property tests
+ it('should match conversation with filter on nested property in meta', () => {
+ const conversation = {
+ meta: {
+ team: {
+ id: 5,
+ name: 'Support',
+ },
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'team_id',
+ filter_operator: 'equal_to',
+ values: { id: 5, name: 'Support' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Edge cases
+ it('should handle null values in conversation', () => {
+ const conversation = {
+ status: null,
+ priority: 'low',
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'is_not_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should handle empty arrays in conversation', () => {
+ const conversation = {
+ labels: [],
+ };
+ const filters = [
+ {
+ attribute_key: 'labels',
+ filter_operator: 'is_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should handle empty string values in conversation', () => {
+ const conversation = {
+ display_id: '',
+ };
+ const filters = [
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'is_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Custom attributes tests
+ it('should match conversation with filter on custom attribute', () => {
+ const conversation = {
+ custom_attributes: {
+ customer_type: 'premium',
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'customer_type',
+ filter_operator: 'equal_to',
+ values: 'premium',
+ query_operator: 'and',
+ attributeModel: 'customAttributes',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with contains operator on custom attribute', () => {
+ const conversation = {
+ custom_attributes: {
+ notes: 'This customer has requested a refund',
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'notes',
+ filter_operator: 'contains',
+ values: 'refund',
+ query_operator: 'and',
+ attributeModel: 'customAttributes',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Combination tests
+ it('should match conversation with combination of different attribute types', () => {
+ const conversation = {
+ status: 'open',
+ created_at: 1647777600000, // March 20, 2022
+ custom_attributes: {
+ customer_type: 'premium',
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'created_at',
+ filter_operator: 'is_greater_than',
+ values: '2022-03-19', // March 19, 2022
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'customer_type',
+ filter_operator: 'equal_to',
+ values: 'premium',
+ query_operator: 'and',
+ attributeModel: 'customAttributes',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Backend alignment tests
+ describe('Backend alignment tests', () => {
+ // Test case for: status='open' AND priority='urgent' OR display_id='12345'
+ describe('with A AND B OR C filter chain', () => {
+ it('matches when all conditions are true', () => {
+ const conversation = {
+ status: 'open', // A: true
+ priority: 'urgent', // B: true
+ display_id: '12345', // C: true
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '12345',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (true AND true) OR true = true OR true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('matches when first condition is false but third is true', () => {
+ const conversation = {
+ status: 'resolved', // A: false
+ priority: 'urgent', // B: true
+ display_id: '12345', // C: true
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '12345',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (false AND true) OR true = false OR true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('matches when first and second condition is false but third is true', () => {
+ const conversation = {
+ status: 'resolved', // A: false
+ priority: 'low', // B: false
+ display_id: '12345', // C: true
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '12345',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (false AND false) OR true = false OR true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('does not match when all conditions are false', () => {
+ const conversation = {
+ status: 'resolved', // A: false
+ priority: 'low', // B: false
+ display_id: '67890', // C: false
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR display_id='12345'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [
+ {
+ id: 'urgent',
+ name: 'Urgent',
+ },
+ ],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '12345',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (false AND false) OR false = false OR false = false
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+ });
+
+ // Test case for: status='open' OR priority='low' AND display_id='67890'
+ describe('with A OR B AND C filter chain', () => {
+ it('matches when first condition is true', () => {
+ const conversation = {
+ status: 'open', // A: true
+ priority: 'urgent', // B: false
+ display_id: '12345', // C: false
+ };
+
+ // This filter chain is: status='open' OR (priority='low' AND display_id='67890')
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'low', name: 'Low' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: true OR (false AND false) = true OR false = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('matches when second and third conditions are true', () => {
+ const conversation = {
+ status: 'resolved', // A: false
+ priority: 'low', // B: true
+ display_id: '67890', // C: true
+ };
+
+ // This filter chain is: status='open' OR (priority='low' AND display_id='67890')
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'low', name: 'Low' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: false OR (true AND true) = false OR true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+ });
+
+ // Test case for: status='open' AND priority='urgent' OR display_id='67890' AND browser_language='tr'
+ describe('with complex filter chain A AND B OR C AND D', () => {
+ it('matches when first two conditions are true', () => {
+ const conversation = {
+ status: 'open', // A: true
+ priority: 'urgent', // B: true
+ display_id: '12345', // C: false
+ additional_attributes: {
+ browser_language: 'en', // D: false
+ },
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR (display_id='67890' AND browser_language='tr')
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'browser_language',
+ filter_operator: 'equal_to',
+ values: 'tr',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (true AND true) OR (false AND false) = true OR false = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('matches when last two conditions are true', () => {
+ const conversation = {
+ status: 'resolved', // A: false
+ priority: 'low', // B: false
+ display_id: '67890', // C: true
+ additional_attributes: {
+ browser_language: 'tr', // D: true
+ },
+ };
+
+ // This filter chain is: (status='open' AND priority='urgent') OR (display_id='67890' AND browser_language='tr')
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'browser_language',
+ filter_operator: 'equal_to',
+ values: 'tr',
+ query_operator: null,
+ },
+ ];
+
+ // Expected: (false AND false) OR (true AND true) = false OR true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+ });
+
+ // Test case for: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum'
+ describe('with mixed operators filter chain', () => {
+ it('matches when all conditions in the chain are true', () => {
+ const conversation = {
+ status: 'open', // A: true
+ priority: 'urgent', // B: true
+ display_id: '12345', // C: false
+ custom_attributes: {
+ conversation_type: 'platinum', // D: true
+ },
+ };
+
+ // This filter chain is: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'conversation_type',
+ filter_operator: 'equal_to',
+ values: 'platinum',
+ query_operator: null,
+ attributeModel: 'customAttributes',
+ },
+ ];
+
+ // Expected: true AND (true OR false) AND true = true AND true AND true = true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('does not match when the last condition is false', () => {
+ const conversation = {
+ status: 'open', // A: true
+ priority: 'urgent', // B: true
+ display_id: '12345', // C: false
+ custom_attributes: {
+ conversation_type: 'silver', // D: false
+ },
+ };
+
+ // This filter chain is: status='open' AND (priority='urgent' OR display_id='67890') AND conversation_type='platinum'
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'equal_to',
+ values: [{ id: 'open', name: 'Open' }],
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'priority',
+ filter_operator: 'equal_to',
+ values: [{ id: 'urgent', name: 'Urgent' }],
+ query_operator: 'or',
+ },
+ {
+ attribute_key: 'display_id',
+ filter_operator: 'equal_to',
+ values: '67890',
+ query_operator: 'and',
+ },
+ {
+ attribute_key: 'conversation_type',
+ filter_operator: 'equal_to',
+ values: 'platinum',
+ query_operator: null,
+ attributeModel: 'customAttributes',
+ },
+ ];
+
+ // true AND true OR false AND false
+ // true OR false
+ // true
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+ });
+ });
+
+ // Test for inbox_id in getValueFromConversation
+ it('should match conversation with equal_to operator for inbox_id', () => {
+ const conversation = { inbox_id: 123 };
+ const filters = [
+ {
+ attribute_key: 'inbox_id',
+ filter_operator: 'equal_to',
+ values: { id: 123, name: 'Support Inbox' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should not match conversation with equal_to operator for inbox_id when values differ', () => {
+ const conversation = { inbox_id: 123 };
+ const filters = [
+ {
+ attribute_key: 'inbox_id',
+ filter_operator: 'equal_to',
+ values: { id: 456, name: 'Sales Inbox' },
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Test for default case (returning null) in getValueFromConversation
+ it('should not match conversation when attribute key is not recognized', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'unknown_attribute',
+ filter_operator: 'equal_to',
+ values: 'some_value',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should match conversation with is_not_present operator for unknown attribute', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'unknown_attribute',
+ filter_operator: 'is_not_present',
+ values: [],
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test for contains operator when value is not a string
+ it('should not match conversation with contains operator when value is not a string', () => {
+ const conversation = {
+ custom_attributes: {
+ numeric_value: 12345,
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'numeric_value',
+ filter_operator: 'contains',
+ values: '123',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ it('should not match conversation with contains operator when value is an array', () => {
+ const conversation = {
+ custom_attributes: {
+ array_value: [1, 2, 3, 4, 5],
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'array_value',
+ filter_operator: 'contains',
+ values: '3',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+
+ // Test for does_not_contain operator when value is not a string
+ it('should match conversation with does_not_contain operator when value is not a string', () => {
+ const conversation = {
+ custom_attributes: {
+ numeric_value: 12345,
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'numeric_value',
+ filter_operator: 'does_not_contain',
+ values: '123',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ it('should match conversation with does_not_contain operator when value is an array', () => {
+ const conversation = {
+ custom_attributes: {
+ array_value: [1, 2, 3, 4, 5],
+ },
+ };
+ const filters = [
+ {
+ attribute_key: 'array_value',
+ filter_operator: 'does_not_contain',
+ values: '3',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(true);
+ });
+
+ // Test for default case in matchesCondition
+ it('should not match conversation with unknown filter operator', () => {
+ const conversation = { status: 'open' };
+ const filters = [
+ {
+ attribute_key: 'status',
+ filter_operator: 'unknown_operator',
+ values: 'open',
+ query_operator: 'and',
+ },
+ ];
+ expect(matchesFilters(conversation, filters)).toBe(false);
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/conversations/specs/helpers.spec.js b/app/javascript/dashboard/store/modules/conversations/specs/helpers.spec.js
new file mode 100644
index 000000000..f9118281e
--- /dev/null
+++ b/app/javascript/dashboard/store/modules/conversations/specs/helpers.spec.js
@@ -0,0 +1,276 @@
+import { describe, it, expect } from 'vitest';
+import { applyRoleFilter } from '../helpers';
+
+describe('Conversation Helpers', () => {
+ describe('#applyRoleFilter', () => {
+ // Test data for conversations
+ const conversationWithAssignee = {
+ meta: {
+ assignee: {
+ id: 1,
+ },
+ },
+ };
+
+ const conversationWithDifferentAssignee = {
+ meta: {
+ assignee: {
+ id: 2,
+ },
+ },
+ };
+
+ const conversationWithoutAssignee = {
+ meta: {
+ assignee: null,
+ },
+ };
+
+ // Test for administrator role
+ it('always returns true for administrator role regardless of permissions', () => {
+ const role = 'administrator';
+ const permissions = [];
+ const currentUserId = 1;
+
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ // Test for agent role
+ it('always returns true for agent role regardless of permissions', () => {
+ const role = 'agent';
+ const permissions = [];
+ const currentUserId = 1;
+
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ // Test for custom role with 'conversation_manage' permission
+ it('returns true for any user with conversation_manage permission', () => {
+ const role = 'custom_role';
+ const permissions = ['conversation_manage'];
+ const currentUserId = 1;
+
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ // Test for custom role with 'conversation_unassigned_manage' permission
+ describe('with conversation_unassigned_manage permission', () => {
+ const role = 'custom_role';
+ const permissions = ['conversation_unassigned_manage'];
+ const currentUserId = 1;
+
+ it('returns true for conversations assigned to the user', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ it('returns true for unassigned conversations', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ it('returns false for conversations assigned to other users', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ });
+ });
+
+ // Test for custom role with 'conversation_participating_manage' permission
+ describe('with conversation_participating_manage permission', () => {
+ const role = 'custom_role';
+ const permissions = ['conversation_participating_manage'];
+ const currentUserId = 1;
+
+ it('returns true for conversations assigned to the user', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ it('returns false for unassigned conversations', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ });
+
+ it('returns false for conversations assigned to other users', () => {
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ });
+ });
+
+ // Test for user with no relevant permissions
+ it('returns false for custom role without any relevant permissions', () => {
+ const role = 'custom_role';
+ const permissions = ['some_other_permission'];
+ const currentUserId = 1;
+
+ expect(
+ applyRoleFilter(
+ conversationWithAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ expect(
+ applyRoleFilter(
+ conversationWithDifferentAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ expect(
+ applyRoleFilter(
+ conversationWithoutAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(false);
+ });
+
+ // Test edge cases for meta.assignee
+ describe('handles edge cases with meta.assignee', () => {
+ const role = 'custom_role';
+ const permissions = ['conversation_unassigned_manage'];
+ const currentUserId = 1;
+
+ it('treats undefined assignee as unassigned', () => {
+ const conversationWithUndefinedAssignee = {
+ meta: {
+ assignee: undefined,
+ },
+ };
+
+ expect(
+ applyRoleFilter(
+ conversationWithUndefinedAssignee,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+
+ it('handles empty meta object', () => {
+ const conversationWithEmptyMeta = {
+ meta: {},
+ };
+
+ expect(
+ applyRoleFilter(
+ conversationWithEmptyMeta,
+ role,
+ permissions,
+ currentUserId
+ )
+ ).toBe(true);
+ });
+ });
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js
index 001cc237e..32d91fb8e 100644
--- a/app/javascript/dashboard/store/modules/inboxes.js
+++ b/app/javascript/dashboard/store/modules/inboxes.js
@@ -122,6 +122,20 @@ export const getters = {
item => item.channel_type !== INBOX_TYPES.EMAIL
);
},
+ getFacebookInboxByInstagramId: $state => instagramId => {
+ return $state.records.find(
+ item =>
+ item.instagram_id === instagramId &&
+ item.channel_type === INBOX_TYPES.FB
+ );
+ },
+ getInstagramInboxByInstagramId: $state => instagramId => {
+ return $state.records.find(
+ item =>
+ item.instagram_id === instagramId &&
+ item.channel_type === INBOX_TYPES.INSTAGRAM
+ );
+ },
};
const sendAnalyticsEvent = channelType => {
diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js
index 496ccbf29..bb5364bb5 100644
--- a/app/javascript/dashboard/store/modules/reports.js
+++ b/app/javascript/dashboard/store/modules/reports.js
@@ -1,5 +1,6 @@
/* eslint no-console: 0 */
import * as types from '../mutation-types';
+import { STATUS } from '../constants';
import Report from '../../api/reports';
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
@@ -9,6 +10,8 @@ import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
+ accountSummaryFetchingStatus: STATUS.FINISHED,
+ botSummaryFetchingStatus: STATUS.FINISHED,
accountReport: {
isFetching: {
conversations_count: false,
@@ -74,6 +77,12 @@ const getters = {
getBotSummary(_state) {
return _state.botSummary;
},
+ getAccountSummaryFetchingStatus(_state) {
+ return _state.accountSummaryFetchingStatus;
+ },
+ getBotSummaryFetchingStatus(_state) {
+ return _state.botSummaryFetchingStatus;
+ },
getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric;
},
@@ -122,6 +131,7 @@ export const actions = {
});
},
fetchAccountSummary({ commit }, reportObj) {
+ commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
reportObj.from,
reportObj.to,
@@ -132,12 +142,14 @@ export const actions = {
)
.then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
+ commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
- commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
+ commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED);
});
},
fetchBotSummary({ commit }, reportObj) {
+ commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getBotSummary({
from: reportObj.from,
to: reportObj.to,
@@ -146,9 +158,10 @@ export const actions = {
})
.then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
+ commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
- commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false);
+ commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
});
},
fetchAccountConversationMetric({ commit }, params = {}) {
@@ -277,6 +290,12 @@ const mutations = {
[types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, { metric, value }) {
_state.accountReport.isFetching[metric] = value;
},
+ [types.default.SET_BOT_SUMMARY_STATUS](_state, status) {
+ _state.botSummaryFetchingStatus = status;
+ },
+ [types.default.SET_ACCOUNT_SUMMARY_STATUS](_state, status) {
+ _state.accountSummaryFetchingStatus = status;
+ },
[types.default.TOGGLE_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag;
},
diff --git a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js
index 92f1328a5..57b4a2f80 100644
--- a/app/javascript/dashboard/store/modules/specs/account/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/account/actions.spec.js
@@ -80,4 +80,41 @@ describe('#actions', () => {
]);
});
});
+
+ describe('#toggleDeletion', () => {
+ it('sends correct actions with delete action if API is success', async () => {
+ axios.post.mockResolvedValue({});
+ await actions.toggleDeletion({ commit }, { action_type: 'delete' });
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
+ ]);
+ expect(axios.post.mock.calls[0][1]).toEqual({
+ action_type: 'delete',
+ });
+ });
+
+ it('sends correct actions with undelete action if API is success', async () => {
+ axios.post.mockResolvedValue({});
+ await actions.toggleDeletion({ commit }, { action_type: 'undelete' });
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
+ ]);
+ expect(axios.post.mock.calls[0][1]).toEqual({
+ action_type: 'undelete',
+ });
+ });
+
+ it('sends correct actions if API is error', async () => {
+ axios.post.mockRejectedValue({ message: 'Incorrect header' });
+ await expect(
+ actions.toggleDeletion({ commit }, { action_type: 'delete' })
+ ).rejects.toThrow(Error);
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
+ [types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
+ ]);
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js b/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
index 90cae45d9..b2fa47313 100644
--- a/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/agentBots/agentBots.spec.js
@@ -1,7 +1,7 @@
import axios from 'axios';
import { actions } from '../../agentBots';
import types from '../../../mutation-types';
-import { agentBotRecords } from './fixtures';
+import { agentBotRecords, agentBotData } from './fixtures';
const commit = vi.fn();
global.axios = axios;
@@ -30,16 +30,22 @@ describe('#actions', () => {
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: agentBotRecords[0] });
- await actions.create({ commit }, agentBotRecords[0]);
+ await actions.create({ commit }, agentBotData);
+
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.ADD_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
]);
+
+ expect(axios.post.mock.calls.length).toBe(1);
+ const formDataArg = axios.post.mock.calls[0][1];
+ expect(formDataArg instanceof FormData).toBe(true);
});
+
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
- await expect(actions.create({ commit })).rejects.toThrow(Error);
+ await expect(actions.create({ commit }, {})).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
@@ -50,17 +56,29 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: agentBotRecords[0] });
- await actions.update({ commit }, agentBotRecords[0]);
+ await actions.update(
+ { commit },
+ {
+ id: agentBotRecords[0].id,
+ data: agentBotData,
+ }
+ );
+
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
[types.EDIT_AGENT_BOT, agentBotRecords[0]],
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
]);
+
+ expect(axios.patch.mock.calls.length).toBe(1);
+ const formDataArg = axios.patch.mock.calls[0][1];
+ expect(formDataArg instanceof FormData).toBe(true);
});
+
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
- actions.update({ commit }, agentBotRecords[0])
+ actions.update({ commit }, { id: 1, data: {} })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
@@ -68,7 +86,6 @@ describe('#actions', () => {
]);
});
});
-
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
diff --git a/app/javascript/dashboard/store/modules/specs/agentBots/fixtures.js b/app/javascript/dashboard/store/modules/specs/agentBots/fixtures.js
index e13735b14..d41585904 100644
--- a/app/javascript/dashboard/store/modules/specs/agentBots/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/agentBots/fixtures.js
@@ -1,15 +1,35 @@
export const agentBotRecords = [
{
+ account_id: 1,
id: 11,
name: 'Agent Bot 11',
description: 'Agent Bot Description',
- type: 'csml',
+ bot_type: 'webhook',
+ thumbnail: 'https://example.com/thumbnail.jpg',
+ bot_config: {},
+ outgoing_url: 'https://example.com/outgoing',
+ access_token: 'hN8QwG769RqBXmme',
+ system_bot: false,
},
{
+ account_id: 1,
id: 12,
name: 'Agent Bot 12',
description: 'Agent Bot Description 12',
- type: 'csml',
+ bot_type: 'webhook',
+ thumbnail: 'https://example.com/thumbnail.jpg',
+ bot_config: {},
+ outgoing_url: 'https://example.com/outgoing',
+ access_token: 'hN8QwG769RqBXmme',
+ system_bot: false,
},
];
+
+export const agentBotData = {
+ name: 'Test Bot',
+ description: 'Test Description',
+ outgoing_url: 'https://test.com',
+ bot_type: 'webhook',
+ avatar: new File([''], 'filename'),
+};
diff --git a/app/javascript/dashboard/store/modules/specs/agentBots/mutations.spec.js b/app/javascript/dashboard/store/modules/specs/agentBots/mutations.spec.js
index aff191ecf..6a8d36a75 100644
--- a/app/javascript/dashboard/store/modules/specs/agentBots/mutations.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/agentBots/mutations.spec.js
@@ -51,4 +51,16 @@ describe('#mutations', () => {
expect(state.agentBotInbox).toEqual({ 3: 2 });
});
});
+ describe('#UPDATE_AGENT_BOT_AVATAR', () => {
+ it('update agent bot avatar', () => {
+ const state = { records: [agentBotRecords[0]] };
+ mutations[types.UPDATE_AGENT_BOT_AVATAR](state, {
+ id: 11,
+ thumbnail: 'https://example.com/thumbnail.jpg',
+ });
+ expect(state.records[0].thumbnail).toEqual(
+ 'https://example.com/thumbnail.jpg'
+ );
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js b/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js
index 4572387ac..43f0efb3a 100644
--- a/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/conversationStats/actions.spec.js
@@ -6,22 +6,41 @@ const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
+vi.mock('@chatwoot/utils', () => ({
+ debounce: vi.fn(fn => {
+ return fn;
+ }),
+}));
+
describe('#actions', () => {
+ beforeEach(() => {
+ vi.useFakeTimers(); // Set up fake timers
+ commit.mockClear();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers(); // Reset to real timers after each test
+ });
+
describe('#get', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({ data: { meta: { mine_count: 1 } } });
- await actions.get(
- { commit, state: { updatedOn: null } },
+ actions.get(
+ { commit, state: { allCount: 0 } },
{ inboxId: 1, assigneeTpe: 'me', status: 'open' }
);
+
+ await vi.runAllTimersAsync();
+ await vi.waitFor(() => expect(commit).toHaveBeenCalled());
+
expect(commit.mock.calls).toEqual([
[types.default.SET_CONV_TAB_META, { mine_count: 1 }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
- await actions.get(
- { commit, state: { updatedOn: null } },
+ actions.get(
+ { commit, state: { allCount: 0 } },
{ inboxId: 1, assigneeTpe: 'me', status: 'open' }
);
expect(commit.mock.calls).toEqual([]);
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
index f7b06d232..382df4828 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/fixtures.js
@@ -9,6 +9,7 @@ export default [
widget_color: null,
website_token: null,
enable_auto_assignment: true,
+ instagram_id: 123456789,
},
{
id: 2,
@@ -62,4 +63,12 @@ export default [
channel_type: 'Channel::Sms',
provider: 'default',
},
+ {
+ id: 7,
+ channel_id: 7,
+ name: 'Test Instagram 1',
+ channel_type: 'Channel::Instagram',
+ instagram_id: 123456789,
+ provider: 'default',
+ },
];
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
index a22f5ebe9..f9ed57d63 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/getters.spec.js
@@ -26,7 +26,7 @@ describe('#getters', () => {
it('dialogFlowEnabledInboxes', () => {
const state = { records: inboxList };
- expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(6);
+ expect(getters.dialogFlowEnabledInboxes(state).length).toEqual(7);
});
it('getInbox', () => {
@@ -43,6 +43,7 @@ describe('#getters', () => {
widget_color: null,
website_token: null,
enable_auto_assignment: true,
+ instagram_id: 123456789,
});
});
@@ -64,4 +65,32 @@ describe('#getters', () => {
isDeleting: false,
});
});
+
+ it('getFacebookInboxByInstagramId', () => {
+ const state = { records: inboxList };
+ expect(getters.getFacebookInboxByInstagramId(state)(123456789)).toEqual({
+ id: 1,
+ channel_id: 1,
+ name: 'Test FacebookPage 1',
+ channel_type: 'Channel::FacebookPage',
+ avatar_url: 'random_image.png',
+ page_id: '12345',
+ widget_color: null,
+ website_token: null,
+ enable_auto_assignment: true,
+ instagram_id: 123456789,
+ });
+ });
+
+ it('getInstagramInboxByInstagramId', () => {
+ const state = { records: inboxList };
+ expect(getters.getInstagramInboxByInstagramId(state)(123456789)).toEqual({
+ id: 7,
+ channel_id: 7,
+ name: 'Test Instagram 1',
+ channel_type: 'Channel::Instagram',
+ instagram_id: 123456789,
+ provider: 'default',
+ });
+ });
});
diff --git a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
index da3c715c4..41e5a1d79 100644
--- a/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/reports/actions.spec.js
@@ -1,14 +1,122 @@
import axios from 'axios';
import { actions } from '../../reports';
+import * as types from '../../../mutation-types';
+import { STATUS } from '../../../constants';
import * as DownloadHelper from 'dashboard/helper/downloadHelper';
+import { flushPromises } from '@vue/test-utils';
global.open = vi.fn();
global.axios = axios;
+global.URL.createObjectURL = vi.fn();
vi.mock('axios');
vi.spyOn(DownloadHelper, 'downloadCsvFile');
describe('#actions', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('#fetchAccountSummary', () => {
+ it('sends correct actions if API is success', async () => {
+ const commit = vi.fn();
+ const reportObj = {
+ from: 1630504922510,
+ to: 1630504922510,
+ type: 'account',
+ id: 1,
+ groupBy: 'day',
+ businessHours: true,
+ };
+ const summaryData = {
+ conversations_count: 10,
+ incoming_messages_count: 20,
+ outgoing_messages_count: 15,
+ avg_first_response_time: 30,
+ avg_resolution_time: 60,
+ resolutions_count: 5,
+ bot_resolutions_count: 2,
+ bot_handoffs_count: 1,
+ reply_time: 25,
+ };
+ axios.get.mockResolvedValue({ data: summaryData });
+
+ actions.fetchAccountSummary({ commit }, reportObj);
+ await flushPromises();
+
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
+ [types.default.SET_ACCOUNT_SUMMARY, summaryData],
+ [types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED],
+ ]);
+ });
+
+ it('sends correct actions if API fails', async () => {
+ const commit = vi.fn();
+ const reportObj = {
+ from: 1630504922510,
+ to: 1630504922510,
+ };
+ axios.get.mockRejectedValue(new Error('API Error'));
+
+ actions.fetchAccountSummary({ commit }, reportObj);
+ await flushPromises();
+
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING],
+ [types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FAILED],
+ ]);
+ });
+ });
+
+ describe('#fetchBotSummary', () => {
+ it('sends correct actions if API is success', async () => {
+ const commit = vi.fn();
+ const reportObj = {
+ from: 1630504922510,
+ to: 1630504922510,
+ groupBy: 'day',
+ businessHours: true,
+ };
+ const summaryData = {
+ bot_resolutions_count: 10,
+ bot_handoffs_count: 5,
+ previous: {
+ bot_resolutions_count: 8,
+ bot_handoffs_count: 4,
+ },
+ };
+ axios.get.mockResolvedValue({ data: summaryData });
+
+ actions.fetchBotSummary({ commit }, reportObj);
+ await flushPromises();
+
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
+ [types.default.SET_BOT_SUMMARY, summaryData],
+ [types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED],
+ ]);
+ });
+
+ it('sends correct actions if API fails', async () => {
+ const commit = vi.fn();
+ const reportObj = {
+ from: 1630504922510,
+ to: 1630504922510,
+ };
+ const error = new Error('API error');
+ axios.get.mockRejectedValueOnce(error);
+
+ actions.fetchBotSummary({ commit }, reportObj);
+ await flushPromises();
+
+ expect(commit.mock.calls).toEqual([
+ [types.default.SET_BOT_SUMMARY_STATUS, STATUS.FETCHING],
+ [types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED],
+ ]);
+ });
+ });
+
describe('#downloadAgentReports', () => {
it('open CSV download prompt if API is success', async () => {
const data = `Agent name,Conversations count,Avg first response time (Minutes),Avg resolution time (Minutes)
@@ -20,7 +128,9 @@ describe('#actions', () => {
to: 1630504922510,
fileName: 'agent-report-01-09-2021.csv',
};
- await actions.downloadAgentReports(1, param);
+ actions.downloadAgentReports(1, param);
+ await flushPromises();
+
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -39,7 +149,9 @@ describe('#actions', () => {
type: 'label',
fileName: 'label-report-01-09-2021.csv',
};
- await actions.downloadLabelReports(1, param);
+ actions.downloadLabelReports(1, param);
+ await flushPromises();
+
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -59,7 +171,9 @@ describe('#actions', () => {
to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv',
};
- await actions.downloadInboxReports(1, param);
+ actions.downloadInboxReports(1, param);
+ await flushPromises();
+
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
@@ -78,7 +192,9 @@ describe('#actions', () => {
to: 1635013800,
fileName: 'inbox-report-24-10-2021.csv',
};
- await actions.downloadInboxReports(1, param);
+ actions.downloadInboxReports(1, param);
+ await flushPromises();
+
expect(DownloadHelper.downloadCsvFile).toBeCalledWith(
param.fileName,
data
diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js
index 68b565dfa..a74207e92 100644
--- a/app/javascript/dashboard/store/mutation-types.js
+++ b/app/javascript/dashboard/store/mutation-types.js
@@ -189,6 +189,8 @@ export default {
SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY',
SET_BOT_SUMMARY: 'SET_BOT_SUMMARY',
TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING',
+ SET_BOT_SUMMARY_STATUS: 'SET_BOT_SUMMARY_STATUS',
+ SET_ACCOUNT_SUMMARY_STATUS: 'SET_ACCOUNT_SUMMARY_STATUS',
SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC',
TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING:
'TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING',
@@ -299,6 +301,7 @@ export default {
EDIT_AGENT_BOT: 'EDIT_AGENT_BOT',
DELETE_AGENT_BOT: 'DELETE_AGENT_BOT',
SET_AGENT_BOT_INBOX: 'SET_AGENT_BOT_INBOX',
+ UPDATE_AGENT_BOT_AVATAR: 'UPDATE_AGENT_BOT_AVATAR',
// MACROS
SET_MACROS_UI_FLAG: 'SET_MACROS_UI_FLAG',
diff --git a/app/javascript/shared/components/emoji/EmojiInput.vue b/app/javascript/shared/components/emoji/EmojiInput.vue
index 0672a4bb3..37917c6c3 100644
--- a/app/javascript/shared/components/emoji/EmojiInput.vue
+++ b/app/javascript/shared/components/emoji/EmojiInput.vue
@@ -1,11 +1,11 @@