Merge branch 'develop' into fix/hc-editor

This commit is contained in:
Muhsin Keloth
2025-08-14 13:56:49 +05:30
committed by GitHub
3651 changed files with 197936 additions and 49575 deletions
@@ -63,10 +63,37 @@ export const actions = {
});
}
},
update: async ({ commit }, updateObj) => {
update: async ({ commit }, { options, ...updateObj }) => {
if (options?.silent !== true) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
}
try {
const response = await AccountAPI.update('', updateObj);
commit(types.default.EDIT_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
delete: async ({ commit }, { id }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
await AccountAPI.update('', updateObj);
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 });
@@ -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 {
@@ -131,6 +172,17 @@ export const actions = {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false });
}
},
resetAccessToken: async ({ commit }, botId) => {
try {
const response = await AgentBotsAPI.resetAccessToken(botId);
commit(types.EDIT_AGENT_BOT, response.data);
return response.data;
} catch (error) {
throwErrorMessage(error);
return null;
}
},
};
export const mutations = {
@@ -150,6 +202,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 {
+31 -2
View File
@@ -2,6 +2,8 @@ import types from '../mutation-types';
import authAPI from '../../api/auth';
import { setUser, clearCookiesOnLogout } from '../utils/api';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
const initialState = {
currentUser: {
@@ -133,6 +135,16 @@ export const actions = {
}
},
updatePassword: async ({ commit }, params) => {
// eslint-disable-next-line no-useless-catch
try {
const response = await authAPI.profilePasswordUpdate(params);
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
throw error;
}
},
deleteAvatar: async ({ commit }) => {
try {
const response = await authAPI.deleteAvatar();
@@ -145,8 +157,15 @@ export const actions = {
updateUISettings: async ({ commit }, params) => {
try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
const isImpersonating = SessionStorage.get(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
if (!isImpersonating) {
const response = await authAPI.updateUISettings(params);
commit(types.SET_CURRENT_USER, response.data);
}
} catch (error) {
// Ignore error
}
@@ -204,6 +223,16 @@ export const actions = {
}
},
resetAccessToken: async ({ commit }) => {
try {
const response = await authAPI.resetAccessToken();
commit(types.SET_CURRENT_USER, response.data);
return true;
} catch (error) {
return false;
}
},
resendConfirmation: async () => {
try {
await authAPI.resendConfirmation();
@@ -3,6 +3,8 @@ import types from '../mutation-types';
import CampaignsAPI from '../../api/campaigns';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const state = {
records: [],
@@ -16,10 +18,35 @@ export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCampaigns: _state => campaignType => {
return _state.records
.filter(record => record.campaign_type === campaignType)
.sort((a1, a2) => a1.id - a2.id);
getCampaigns:
_state =>
(campaignType, inboxChannelTypes = null) => {
let filteredRecords = _state.records.filter(
record => record.campaign_type === campaignType
);
if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) {
filteredRecords = filteredRecords.filter(record => {
return (
record.inbox &&
inboxChannelTypes.includes(record.inbox.channel_type)
);
});
}
return filteredRecords.sort((a1, a2) => a1.id - a2.id);
},
getSMSCampaigns: (_state, _getters) => {
const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes);
},
getWhatsAppCampaigns: (_state, _getters) => {
const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes);
},
getLiveChatCampaigns: (_state, _getters) => {
const liveChatChannelTypes = [INBOX_TYPES.WEB];
return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes);
},
getAllCampaigns: _state => {
return _state.records;
@@ -75,6 +75,21 @@ export const actions = {
}
},
active: async ({ commit }, { page = 1, sortAttr } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.active(page, sortAttr);
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { id }) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingItem: true });
try {
@@ -5,12 +5,14 @@ export const initialState = {
contactRecords: [],
conversationRecords: [],
messageRecords: [],
articleRecords: [],
uiFlags: {
isFetching: false,
isSearchCompleted: false,
contact: { isFetching: false },
conversation: { isFetching: false },
message: { isFetching: false },
article: { isFetching: false },
},
};
@@ -27,6 +29,9 @@ export const getters = {
getMessageRecords(state) {
return state.messageRecords;
},
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -65,6 +70,7 @@ export const actions = {
dispatch('contactSearch', { q }),
dispatch('conversationSearch', { q }),
dispatch('messageSearch', { q }),
dispatch('articleSearch', { q }),
]);
} catch (error) {
// Ignore error
@@ -108,6 +114,17 @@ export const actions = {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, { q, page = 1 }) {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ q, page });
commit(types.ARTICLE_SEARCH_SET, data.payload.articles);
} catch (error) {
// Ignore error
} finally {
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async clearSearchResults({ commit }) {
commit(types.CLEAR_SEARCH_RESULTS);
},
@@ -126,6 +143,9 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET](state, records) {
state.messageRecords = [...state.messageRecords, ...records];
},
[types.ARTICLE_SEARCH_SET](state, records) {
state.articleRecords = [...state.articleRecords, ...records];
},
[types.SEARCH_CONVERSATIONS_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
@@ -141,10 +161,14 @@ export const mutations = {
[types.MESSAGE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.message = { ...state.uiFlags.message, ...uiFlags };
},
[types.ARTICLE_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.article = { ...state.uiFlags.article, ...uiFlags };
},
[types.CLEAR_SEARCH_RESULTS](state) {
state.contactRecords = [];
state.conversationRecords = [];
state.messageRecords = [];
state.articleRecords = [];
},
};
@@ -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 > 1000) {
// 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) {
@@ -327,6 +327,16 @@ const actions = {
}
},
deleteConversation: async ({ commit, dispatch }, conversationId) => {
try {
await ConversationApi.delete(conversationId);
commit(types.DELETE_CONVERSATION, conversationId);
dispatch('conversationStats/get', {}, { root: true });
} catch (error) {
throw new Error(error);
}
},
addConversation({ commit, state, dispatch, rootState }, conversation) {
const { currentInbox, appliedFilters } = state;
const {
@@ -489,6 +499,15 @@ const actions = {
commit(types.SET_CONTEXT_MENU_CHAT_ID, chatId);
},
getInboxCaptainAssistantById: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.getInboxAssistant(conversationId);
commit(types.SET_INBOX_CAPTAIN_ASSISTANT, response.data);
} catch (error) {
// Handle error
}
},
...messageReadActions,
...messageTranslateActions,
};
@@ -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,36 @@ const getters = {
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getFilteredConversations: (
{ allConversations, chatSortFilter, appliedFilters },
_,
__,
rootGetters
) => {
const currentUser = rootGetters.getCurrentUser;
const currentUserId = rootGetters.getCurrentUser.id;
const currentAccountId = rootGetters.getCurrentAccountId;
const permissions = getUserPermissions(currentUser, currentAccountId);
const userRole = getUserRole(currentUser, currentAccountId);
return allConversations
.filter(conversation => {
const matchesFilterResult = matchesFilters(
conversation,
appliedFilters
);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return matchesFilterResult && allowedForRole;
})
.sort((a, b) => sortComparator(a, b, chatSortFilter));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
conversation => conversation.id === selectedChatId
@@ -27,18 +62,12 @@ const getters = {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
const lastEmail = [...messages].reverse().find(message => {
const {
content_attributes: contentAttributes = {},
message_type: messageType,
} = message;
const { email = {} } = contentAttributes;
const isIncomingOrOutgoing =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.INCOMING;
if (email.from && isIncomingOrOutgoing) {
return true;
}
return false;
const { message_type: messageType } = message;
if (message.private) return false;
return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes(
messageType
);
});
return lastEmail;
@@ -73,10 +102,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,
@@ -114,6 +157,10 @@ const getters = {
getContextMenuChatId: _state => {
return _state.contextMenuChatId;
},
getCopilotAssistant: _state => {
return _state.copilotAssistant;
},
};
export default getters;
@@ -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<string>} 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'],
@@ -0,0 +1,379 @@
/**
* 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';
import { coerceToDate } from '@chatwoot/utils';
/**
* 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;
};
/**
* Compares two date values using a comparison function
* @param {*} conversationValue - The conversation value to compare
* @param {*} filterValue - The filter value to compare against
* @param {Function} compareFn - The comparison function to apply
* @returns {Boolean} - Returns true if the comparison succeeds, false otherwise
*/
const compareDates = (conversationValue, filterValue, compareFn) => {
const conversationDate = coerceToDate(conversationValue);
// In saved views, the filterValue might be returned as an Array
// In conversation list, when filtering, the filterValue will be returned as a string
const valueToCompare = Array.isArray(filterValue)
? filterValue[0]
: filterValue;
const filterDate = coerceToDate(valueToCompare);
if (conversationDate === null || filterDate === null) return false;
return compareFn(conversationDate, filterDate);
};
/**
* 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 compareDates(conversationValue, filterValue, (a, b) => a > b);
case 'is_less_than':
return compareDates(conversationValue, filterValue, (a, b) => a < b);
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));
};
@@ -21,6 +21,7 @@ const state = {
conversationLastSeen: null,
syncConversationsMessages: {},
conversationFilters: {},
copilotAssistant: {},
};
// mutations
@@ -77,10 +78,7 @@ export const mutations = {
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
const attachments = _state.attachments[id] || [];
attachments.push(...data);
_state.attachments[id] = [...attachments];
_state.attachments[id] = [...data];
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
@@ -206,18 +204,26 @@ export const mutations = {
_state.allConversations.push(conversation);
},
[types.DELETE_CONVERSATION](_state, conversationId) {
_state.allConversations = _state.allConversations.filter(
c => c.id !== conversationId
);
},
[types.UPDATE_CONVERSATION](_state, conversation) {
const { allConversations } = _state;
const currentConversationIndex = allConversations.findIndex(
c => c.id === conversation.id
);
if (currentConversationIndex > -1) {
const { messages, ...conversationAttributes } = conversation;
const currentConversation = {
...allConversations[currentConversationIndex],
...conversationAttributes,
};
allConversations[currentConversationIndex] = currentConversation;
const index = allConversations.findIndex(c => c.id === conversation.id);
if (index > -1) {
const selectedConversation = allConversations[index];
// ignore out of order events
if (conversation.updated_at < selectedConversation.updated_at) {
return;
}
const { messages, ...updates } = conversation;
allConversations[index] = { ...selectedConversation, ...updates };
if (_state.selectedChatId === conversation.id) {
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
@@ -309,6 +315,9 @@ export const mutations = {
[types.UPDATE_CHAT_LIST_FILTERS](_state, data) {
_state.conversationFilters = { ..._state.conversationFilters, ...data };
},
[types.SET_INBOX_CAPTAIN_ASSISTANT](_state, data) {
_state.copilotAssistant = data.assistant;
},
};
export default {
@@ -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);
});
});
});
});
@@ -116,4 +116,24 @@ export const actions = {
isSwitching,
});
},
sendCnameInstructions: async (_, { portalSlug, email }) => {
try {
await portalAPIs.sendCnameInstructions(portalSlug, email);
} catch (error) {
throwErrorMessage(error);
}
},
sslStatus: async ({ commit }, { portalSlug }) => {
try {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: true });
const { data } = await portalAPIs.sslStatus(portalSlug);
commit(types.SET_SSL_SETTINGS, { portalSlug, sslSettings: data });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetchingSSLStatus: false });
}
},
};
@@ -8,6 +8,7 @@ export const getters = {
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
@@ -6,6 +6,7 @@ export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
};
const state = {
@@ -13,6 +13,7 @@ export const types = {
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
@@ -110,4 +111,18 @@ export const mutations = {
[types.SET_PORTAL_SWITCHING_FLAG]($state, { isSwitching }) {
$state.uiFlags.isSwitching = isSwitching;
},
[types.SET_SSL_SETTINGS]($state, { portalSlug, sslSettings }) {
const portal = $state.portals.byId[portalSlug];
$state.portals.byId = {
...$state.portals.byId,
[portalSlug]: {
...portal,
ssl_settings: {
...portal.ssl_settings,
...sslSettings,
},
},
};
},
};
@@ -135,6 +135,36 @@ describe('#actions', () => {
});
});
describe('#sslStatus', () => {
it('commits SET_SSL_SETTINGS with data from API', async () => {
axios.get.mockResolvedValue({
data: { status: 'active', verification_errors: [] },
});
await actions.sslStatus({ commit }, { portalSlug: 'domain' });
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[
types.SET_SSL_SETTINGS,
{
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: [] },
},
],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
it('throws error and does not commit when API fails', async () => {
axios.get.mockRejectedValue({ message: 'error' });
await expect(
actions.sslStatus({ commit }, { portalSlug: 'domain' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingSSLStatus: true }],
[types.SET_UI_FLAG, { isFetchingSSLStatus: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({});
@@ -89,6 +89,25 @@ describe('#mutations', () => {
isFetching: true,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
});
});
});
describe('[types.SET_SSL_SETTINGS]', () => {
it('merges new ssl settings into existing portal.ssl_settings', () => {
state.portals.byId.domain = {
slug: 'domain',
ssl_settings: { cf_status: 'pending' },
};
mutations[types.SET_SSL_SETTINGS](state, {
portalSlug: 'domain',
sslSettings: { status: 'active', verification_errors: ['error'] },
});
expect(state.portals.byId.domain.ssl_settings).toEqual({
cf_status: 'pending',
status: 'active',
verification_errors: ['error'],
});
});
});
@@ -5,33 +5,12 @@ import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
import TwilioChannel from '../../api/channel/twilioChannel';
import WhatsappChannel from '../../api/channel/whatsappChannel';
import { throwErrorMessage } from '../utils/api';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import camelcaseKeys from 'camelcase-keys';
import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events';
const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
@@ -65,15 +44,52 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
// filtering out the whatsapp templates with media
if (messagesTemplates instanceof Array) {
return messagesTemplates.filter(template => {
return !template.components.some(
i => i.format === 'IMAGE' || i.format === 'VIDEO'
);
});
return messagesTemplates;
},
getFilteredWhatsAppTemplates: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
const {
message_templates: whatsAppMessageTemplates,
additional_attributes: additionalAttributes,
} = inbox || {};
const { message_templates: apiInboxMessageTemplates } =
additionalAttributes || {};
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
if (!templates || !Array.isArray(templates)) {
return [];
}
return [];
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -117,11 +133,30 @@ export const getters = {
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
);
},
getWhatsAppInboxes($state) {
return $state.records.filter(
item => item.channel_type === INBOX_TYPES.WHATSAPP
);
},
dialogFlowEnabledInboxes($state) {
return $state.records.filter(
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 => {
@@ -206,6 +241,25 @@ export const actions = {
throw new Error(error);
}
},
createWhatsAppEmbeddedSignup: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WhatsappChannel.createEmbeddedSignup(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('whatsapp');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
...channelActions,
// TODO: Extract other create channel methods to separate files to reduce file size
// - createChannel
// - createWebsiteChannel
// - createTwilioChannel
// - createFBChannel
updateInbox: async ({ commit }, { id, formData = true, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: true });
try {
@@ -268,6 +322,13 @@ export const actions = {
throw new Error(error);
}
},
syncTemplates: async (_, inboxId) => {
try {
await InboxesAPI.syncTemplates(inboxId);
} catch (error) {
throw new Error(error);
}
},
};
export const mutations = {
@@ -0,0 +1,52 @@
import * as types from '../../mutation-types';
import InboxesAPI from '../../../api/inboxes';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
export const buildInboxData = inboxParams => {
const formData = new FormData();
const { channel = {}, ...inboxProperties } = inboxParams;
Object.keys(inboxProperties).forEach(key => {
formData.append(key, inboxProperties[key]);
});
const { selectedFeatureFlags, ...channelParams } = channel;
// selectedFeatureFlags needs to be empty when creating a website channel
if (selectedFeatureFlags) {
if (selectedFeatureFlags.length) {
selectedFeatureFlags.forEach(featureFlag => {
formData.append(`channel[selected_feature_flags][]`, featureFlag);
});
} else {
formData.append('channel[selected_feature_flags][]', '');
}
}
Object.keys(channelParams).forEach(key => {
formData.append(`channel[${key}]`, channel[key]);
});
return formData;
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const channelActions = {
createVoiceChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await InboxesAPI.create({
name: params.name,
channel: { ...params.voice, type: 'voice' },
});
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('voice');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
};
@@ -26,6 +26,9 @@ export const getters = {
.filter(record => record.show_on_sidebar)
.sort((a, b) => a.title.localeCompare(b.title));
},
getLabelById: _state => id => {
return _state.records.find(record => record.id === Number(id));
},
};
export const actions = {
@@ -1,16 +1,17 @@
/* 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';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
import {
reconcileHeatmapData,
clampDataBetweenTimeline,
} from 'shared/helpers/ReportsDataHelper';
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
import liveReports from '../../api/liveReports';
const state = {
fetchingStatus: false,
accountSummaryFetchingStatus: STATUS.FINISHED,
botSummaryFetchingStatus: STATUS.FINISHED,
accountReport: {
isFetching: {
conversations_count: false,
@@ -57,10 +58,12 @@ const state = {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
@@ -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;
},
@@ -83,6 +92,9 @@ const getters = {
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
@@ -114,16 +126,12 @@ export const actions = {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
data = reconcileHeatmapData(
data,
state.overview.accountConversationHeatmap
);
commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
},
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
reportObj.from,
reportObj.to,
@@ -134,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,
@@ -148,14 +158,16 @@ 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 }, reportObj) {
fetchAccountConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type)
liveReports
.getConversationMetric(params)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
@@ -167,9 +179,10 @@ export const actions = {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }, reportObj) {
fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
Report.getConversationMetric(reportObj.type, reportObj.page)
liveReports
.getGroupedConversations({ groupBy: 'assignee_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
@@ -181,6 +194,18 @@ export const actions = {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchTeamConversationMetric({ commit }) {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'team_id' })
.then(teamMetric => {
commit(types.default.SET_TEAM_CONVERSATION_METRIC, teamMetric.data);
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING, false);
});
},
downloadAgentReports(_, reportObj) {
return Report.getAgentReports(reportObj)
.then(response => {
@@ -234,7 +259,7 @@ export const actions = {
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV()
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
downloadCsvFile(
generateFileName({
@@ -265,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;
},
@@ -286,6 +317,12 @@ const mutations = {
[types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAgentConversationMetric = flag;
},
[types.default.SET_TEAM_CONVERSATION_METRIC](_state, metricData) {
_state.overview.teamConversationMetric = metricData;
},
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
};
export default {
@@ -39,10 +39,13 @@ describe('#actions', () => {
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue();
axios.patch.mockResolvedValue({
data: { id: 1, name: 'John' },
});
await actions.update({ commit, getters }, accountData);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.EDIT_ACCOUNT, { id: 1, name: 'John' }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
});
@@ -80,4 +83,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 }],
]);
});
});
});
@@ -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] });
@@ -153,4 +170,21 @@ describe('#actions', () => {
]);
});
});
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { ...agentBotRecords[0], access_token: 'new_token_123' },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken(
{ commit },
agentBotRecords[0].id
);
expect(commit.mock.calls).toEqual([
[types.EDIT_AGENT_BOT, mockResponse.data],
]);
expect(result).toBe(mockResponse.data);
});
});
});
@@ -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'),
};
@@ -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'
);
});
});
});
@@ -228,4 +228,20 @@ describe('#actions', () => {
);
});
});
describe('#resetAccessToken', () => {
it('sends correct actions if API is success', async () => {
const mockResponse = {
data: { id: 1, name: 'John', access_token: 'new_token_123' },
headers: { expiry: 581842904 },
};
axios.post.mockResolvedValue(mockResponse);
const result = await actions.resetAccessToken({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CURRENT_USER, mockResponse.data],
]);
expect(result).toBe(true);
});
});
});
@@ -11,6 +11,11 @@ export default [
url: 'https://github.com',
time_on_page: 10,
},
inbox: {
id: 1,
channel_type: 'Channel::WebWidget',
name: 'Web Widget',
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
@@ -24,6 +29,11 @@ export default [
url: 'https://chatwoot.com',
time_on_page: '20',
},
inbox: {
id: 2,
channel_type: 'Channel::TwilioSms',
name: 'Twilio SMS',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
@@ -39,7 +49,52 @@ export default [
url: 'https://noshow.com',
time_on_page: 10,
},
inbox: {
id: 3,
channel_type: 'Channel::WebWidget',
name: 'Web Widget 2',
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
{
id: 4,
title: 'WhatsApp Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Hello {{name}}, your order is ready!',
enabled: true,
trigger_rules: {},
inbox: {
id: 4,
channel_type: 'Channel::Whatsapp',
name: 'WhatsApp Business',
},
template_params: {
name: 'order_ready',
namespace: 'business_namespace',
language: 'en_US',
processed_params: { name: 'John' },
},
created_at: '2021-05-03T12:15:35.828Z',
updated_at: '2021-05-03T12:15:35.828Z',
},
{
id: 5,
title: 'SMS Promotion',
description: null,
account_id: 1,
campaign_type: 'one_off',
message: 'Get 20% off your next order!',
enabled: true,
trigger_rules: {},
inbox: {
id: 5,
channel_type: 'Channel::Sms',
name: 'SMS Channel',
},
created_at: '2021-05-03T14:15:35.828Z',
updated_at: '2021-05-03T14:15:35.828Z',
},
];
@@ -13,20 +13,58 @@ describe('#getters', () => {
it('get one_off campaigns', () => {
const state = { records: campaigns };
expect(getters.getCampaigns(state)('one_off')).toEqual([
{
id: 2,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
campaign_type: 'one_off',
campaigns[1],
campaigns[3],
campaigns[4],
]);
});
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
it('get campaigns by channel type', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
).toEqual([campaigns[3]]);
});
it('get campaigns by multiple channel types', () => {
const state = { records: campaigns };
expect(
getters.getCampaigns(state)('one_off', [
'Channel::TwilioSms',
'Channel::Sms',
])
).toEqual([campaigns[1], campaigns[4]]);
});
it('get SMS campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([
campaigns[1],
campaigns[4],
]);
});
it('get WhatsApp campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([
campaigns[3],
]);
});
it('get Live Chat campaigns', () => {
const state = { records: campaigns };
const mockGetters = {
getCampaigns: getters.getCampaigns(state),
};
expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([
campaigns[0],
campaigns[2],
]);
});
@@ -70,6 +70,30 @@ describe('#actions', () => {
});
});
describe('#active', () => {
it('sends correct mutations if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: contactList, meta: { count: 100, current_page: 1 } },
});
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.CLEAR_CONTACTS],
[types.SET_CONTACTS, contactList],
[types.SET_CONTACT_META, { count: 100, current_page: 1 }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct mutations if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.active({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_CONTACT_UI_FLAG, { isFetching: true }],
[types.SET_CONTACT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#update', () => {
it('sends correct mutations if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: contactList[0] } });
@@ -75,6 +75,7 @@ describe('#actions', () => {
q: 'test',
});
expect(dispatch).toHaveBeenCalledWith('messageSearch', { q: 'test' });
expect(dispatch).toHaveBeenCalledWith('articleSearch', { q: 'test' });
});
});
@@ -150,6 +151,30 @@ describe('#actions', () => {
});
});
describe('#articleSearch', () => {
it('should handle successful article search', async () => {
axios.get.mockResolvedValue({
data: { payload: { articles: [{ id: 1 }] } },
});
await actions.articleSearch({ commit }, { q: 'test', page: 1 });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET, [{ id: 1 }]],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
it('should handle failed article search', async () => {
axios.get.mockRejectedValue({});
await actions.articleSearch({ commit }, { q: 'test' });
expect(commit.mock.calls).toEqual([
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true }],
[types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#clearSearchResults', () => {
it('should commit clear search results mutation', () => {
actions.clearSearchResults({ commit });
@@ -37,6 +37,15 @@ describe('#getters', () => {
]);
});
it('getArticleRecords', () => {
const state = {
articleRecords: [{ id: 1, title: 'Article 1' }],
};
expect(getters.getArticleRecords(state)).toEqual([
{ id: 1, title: 'Article 1' },
]);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
@@ -45,6 +54,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
},
};
expect(getters.getUIFlags(state)).toEqual({
@@ -53,6 +63,7 @@ describe('#getters', () => {
contact: { isFetching: true },
message: { isFetching: false },
conversation: { isFetching: false },
article: { isFetching: false },
});
});
});
@@ -101,17 +101,39 @@ describe('#mutations', () => {
});
});
describe('#ARTICLE_SEARCH_SET', () => {
it('should append new article records to existing ones', () => {
const state = { articleRecords: [{ id: 1 }] };
mutations[types.ARTICLE_SEARCH_SET](state, [{ id: 2 }]);
expect(state.articleRecords).toEqual([{ id: 1 }, { id: 2 }]);
});
});
describe('#ARTICLE_SEARCH_SET_UI_FLAG', () => {
it('set article search UI flags correctly', () => {
const state = {
uiFlags: {
article: { isFetching: true },
},
};
mutations[types.ARTICLE_SEARCH_SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags.article).toEqual({ isFetching: false });
});
});
describe('#CLEAR_SEARCH_RESULTS', () => {
it('should clear all search records', () => {
const state = {
contactRecords: [{ id: 1 }],
conversationRecords: [{ id: 1 }],
messageRecords: [{ id: 1 }],
articleRecords: [{ id: 1 }],
};
mutations[types.CLEAR_SEARCH_RESULTS](state);
expect(state.contactRecords).toEqual([]);
expect(state.conversationRecords).toEqual([]);
expect(state.messageRecords).toEqual([]);
expect(state.articleRecords).toEqual([]);
});
});
});
@@ -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([]);
@@ -513,6 +513,28 @@ describe('#deleteMessage', () => {
expect(commit.mock.calls).toEqual([]);
});
describe('#deleteConversation', () => {
it('send correct actions if API is success', async () => {
axios.delete.mockResolvedValue({
data: { id: 1 },
});
await actions.deleteConversation({ commit, dispatch }, 1);
expect(commit.mock.calls).toEqual([[types.DELETE_CONVERSATION, 1]]);
expect(dispatch.mock.calls).toEqual([
['conversationStats/get', {}, { root: true }],
]);
});
it('send no actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.deleteConversation({ commit, dispatch }, 1)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([]);
});
});
describe('#updateCustomAttributes', () => {
it('update conversation custom attributes', async () => {
axios.post.mockResolvedValue({
@@ -687,4 +709,23 @@ describe('#addMentions', () => {
]);
});
});
describe('#getInboxCaptainAssistantById', () => {
it('fetches inbox assistant by id', async () => {
axios.get.mockResolvedValue({
data: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
});
await actions.getInboxCaptainAssistantById({ commit }, 1);
expect(commit.mock.calls).toEqual([
[
types.SET_INBOX_CAPTAIN_ASSISTANT,
{ id: 1, name: 'Assistant', description: 'Assistant description' },
],
]);
});
});
});
@@ -308,4 +308,325 @@ describe('#getters', () => {
});
});
});
describe('#getCopilotAssistant', () => {
it('get copilot assistant', () => {
const state = {
copilotAssistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
};
expect(getters.getCopilotAssistant(state)).toEqual({
id: 1,
name: 'Assistant',
description: 'Assistant description',
});
});
});
describe('#getFilteredConversations', () => {
const mockConversations = [
{
id: 1,
status: 'open',
meta: { assignee: { id: 1 } },
last_activity_at: 1000,
},
{
id: 2,
status: 'open',
meta: {},
last_activity_at: 2000,
},
{
id: 3,
status: 'resolved',
meta: { assignee: { id: 2 } },
last_activity_at: 3000,
},
];
const mockRootGetters = {
getCurrentUser: {
id: 1,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
getCurrentAccountId: 1,
};
it('filters conversations based on role permissions for administrator', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'administrator', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations based on role permissions for agent', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [{ id: 1, role: 'agent', permissions: [] }],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
expect(result).toEqual([
mockConversations[2],
mockConversations[1],
mockConversations[0],
]);
});
it('filters conversations for custom role with conversation_unassigned_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_unassigned_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should include conversation assigned to user (id: 1) and unassigned conversation
expect(result).toEqual([mockConversations[1], mockConversations[0]]);
});
it('filters conversations for custom role with conversation_participating_manage permission', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('filters conversations for custom role with no permissions', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: [],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should return empty array as user has no permissions
expect(result).toEqual([]);
});
it('applies filters and role permissions together', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
query_operator: 'and',
},
],
};
const rootGetters = {
...mockRootGetters,
getCurrentUser: {
...mockRootGetters.getCurrentUser,
accounts: [
{
id: 1,
custom_role_id: 5,
permissions: ['conversation_participating_manage'],
},
],
},
};
const result = getters.getFilteredConversations(
state,
{},
{},
rootGetters
);
// Should only include open conversation assigned to user (id: 1)
expect(result).toEqual([mockConversations[0]]);
});
it('returns empty array when no conversations match filters', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_desc',
appliedFilters: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['pending'],
query_operator: 'and',
},
],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([]);
});
it('sorts filtered conversations according to chatSortFilter', () => {
const state = {
allConversations: mockConversations,
chatSortFilter: 'last_activity_at_asc',
appliedFilters: [],
};
const result = getters.getFilteredConversations(
state,
{},
{},
mockRootGetters
);
expect(result).toEqual([
mockConversations[0],
mockConversations[1],
mockConversations[2],
]);
});
});
});
@@ -1,3 +1,4 @@
import { describe } from 'vitest';
import types from '../../../mutation-types';
import { mutations } from '../../conversations';
@@ -553,4 +554,408 @@ describe('#mutations', () => {
});
});
});
describe('#SET_INBOX_CAPTAIN_ASSISTANT', () => {
it('set inbox captain assistant', () => {
const state = { copilotAssistant: {} };
const data = {
assistant: {
id: 1,
name: 'Assistant',
description: 'Assistant description',
},
};
mutations[types.SET_INBOX_CAPTAIN_ASSISTANT](state, data);
expect(state.copilotAssistant).toEqual(data.assistant);
});
});
describe('#SET_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to true on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: false }],
selectedChatId: 1,
};
mutations[types.SET_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(true);
});
});
describe('#CLEAR_ALL_MESSAGES_LOADED', () => {
it('should set allMessagesLoaded to false on selected chat', () => {
const state = {
allConversations: [{ id: 1, allMessagesLoaded: true }],
selectedChatId: 1,
};
mutations[types.CLEAR_ALL_MESSAGES_LOADED](state);
expect(state.allConversations[0].allMessagesLoaded).toBe(false);
});
});
describe('#SET_PREVIOUS_CONVERSATIONS', () => {
it('should prepend messages to conversation messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [{ id: 'msg1' }] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([
{ id: 'msg1' },
{ id: 'msg2' },
]);
});
it('should not modify messages if data is empty', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'msg2' }] }],
};
const payload = { id: 1, data: [] };
mutations[types.SET_PREVIOUS_CONVERSATIONS](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'msg2' }]);
});
});
describe('#SET_MISSING_MESSAGES', () => {
it('should replace message array with new data', () => {
const state = {
allConversations: [{ id: 1, messages: [{ id: 'old' }] }],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations[0].messages).toEqual([{ id: 'new' }]);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = { id: 1, data: [{ id: 'new' }] };
mutations[types.SET_MISSING_MESSAGES](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#ASSIGN_AGENT', () => {
it('should assign agent to selected conversation', () => {
const assignee = { id: 1, name: 'Agent' };
const state = {
allConversations: [{ id: 1, meta: {} }],
selectedChatId: 1,
};
mutations[types.ASSIGN_AGENT](state, assignee);
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
});
});
describe('#ASSIGN_PRIORITY', () => {
it('should assign priority to conversation', () => {
const priority = { title: 'Urgent', value: 'urgent' };
const state = {
allConversations: [{ id: 1 }],
};
mutations[types.ASSIGN_PRIORITY](state, {
priority,
conversationId: 1,
});
expect(state.allConversations[0].priority).toEqual(priority);
});
});
describe('#MUTE_CONVERSATION', () => {
it('should mute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: false }],
selectedChatId: 1,
};
mutations[types.MUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(true);
});
});
describe('#UNMUTE_CONVERSATION', () => {
it('should unmute selected conversation', () => {
const state = {
allConversations: [{ id: 1, muted: true }],
selectedChatId: 1,
};
mutations[types.UNMUTE_CONVERSATION](state);
expect(state.allConversations[0].muted).toBe(false);
});
});
describe('#UPDATE_CONVERSATION', () => {
it('should update existing conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
messages: [{ id: 'msg1' }],
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg2' }],
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0]).toEqual({
id: 1,
status: 'resolved',
updated_at: 200,
messages: [{ id: 'msg1' }],
});
});
it('should add conversation if not found', () => {
const state = {
allConversations: [],
};
const conversation = {
id: 1,
status: 'open',
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
it('should emit events if updating selected conversation', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
selectedChatId: 1,
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 200,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
});
it('should ignore updates with older timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 200,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('open');
});
it('should allow updates with same timestamps', () => {
const state = {
allConversations: [
{
id: 1,
status: 'open',
updated_at: 100,
},
],
};
const conversation = {
id: 1,
status: 'resolved',
updated_at: 100,
};
mutations[types.UPDATE_CONVERSATION](state, conversation);
expect(state.allConversations[0].status).toEqual('resolved');
});
});
describe('#UPDATE_CONVERSATION_CONTACT', () => {
it('should update conversation contact data', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1, name: 'Old Name' } } },
],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
// The mutation extracts all properties except conversationId
const { conversationId, ...contact } = payload;
expect(state.allConversations[0].meta.sender).toEqual(contact);
});
it('should do nothing if conversation is not found', () => {
const state = {
allConversations: [],
};
const payload = {
conversationId: 1,
id: 1,
name: 'New Name',
};
mutations[types.UPDATE_CONVERSATION_CONTACT](state, payload);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_ACTIVE_INBOX', () => {
it('should set current inbox as integer', () => {
const state = {
currentInbox: null,
};
mutations[types.SET_ACTIVE_INBOX](state, '1');
expect(state.currentInbox).toBe(1);
});
it('should set null if no inbox ID provided', () => {
const state = {
currentInbox: 1,
};
mutations[types.SET_ACTIVE_INBOX](state, null);
expect(state.currentInbox).toBe(null);
});
});
describe('#CLEAR_CONTACT_CONVERSATIONS', () => {
it('should remove all conversations with matching contact ID', () => {
const state = {
allConversations: [
{ id: 1, meta: { sender: { id: 1 } } },
{ id: 2, meta: { sender: { id: 2 } } },
{ id: 3, meta: { sender: { id: 1 } } },
],
};
mutations[types.CLEAR_CONTACT_CONVERSATIONS](state, 1);
expect(state.allConversations).toHaveLength(1);
expect(state.allConversations[0].id).toBe(2);
});
});
describe('#ADD_CONVERSATION', () => {
it('should add a new conversation', () => {
const state = {
allConversations: [],
};
const conversation = { id: 1, messages: [] };
mutations[types.ADD_CONVERSATION](state, conversation);
expect(state.allConversations).toEqual([conversation]);
});
});
describe('#DELETE_CONVERSATION', () => {
it('should delete a conversation', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.DELETE_CONVERSATION](state, 1);
expect(state.allConversations).toEqual([]);
});
});
describe('#SET_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to true', () => {
const state = {
listLoadingStatus: false,
};
mutations[types.SET_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(true);
});
});
describe('#CLEAR_LIST_LOADING_STATUS', () => {
it('should set listLoadingStatus to false', () => {
const state = {
listLoadingStatus: true,
};
mutations[types.CLEAR_LIST_LOADING_STATUS](state);
expect(state.listLoadingStatus).toBe(false);
});
});
describe('#CHANGE_CHAT_STATUS_FILTER', () => {
it('should update chat status filter', () => {
const state = {
chatStatusFilter: 'open',
};
mutations[types.CHANGE_CHAT_STATUS_FILTER](state, 'resolved');
expect(state.chatStatusFilter).toBe('resolved');
});
});
describe('#UPDATE_ASSIGNEE', () => {
it('should update assignee on conversation', () => {
const state = {
allConversations: [{ id: 1, meta: { assignee: null } }],
};
const payload = {
id: 1,
assignee: { id: 1, name: 'Agent' },
};
mutations[types.UPDATE_ASSIGNEE](state, payload);
expect(state.allConversations[0].meta.assignee).toEqual(payload.assignee);
});
});
describe('#SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION', () => {
it('should update the sync conversation message ID', () => {
const state = {
syncConversationsMessages: {},
};
mutations[types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION](state, {
conversationId: 1,
messageId: 100,
});
expect(state.syncConversationsMessages[1]).toBe(100);
});
});
});
@@ -62,6 +62,28 @@ describe('#actions', () => {
});
});
describe('#createVoiceChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
await actions.createVoiceChannel({ commit }, inboxList[0]);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.ADD_INBOXES, inboxList[0]],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.createVoiceChannel({ commit })).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_INBOXES_UI_FLAG, { isCreating: true }],
[types.default.SET_INBOXES_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#createFBChannel', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: inboxList[0] });
@@ -209,4 +231,28 @@ describe('#actions', () => {
).rejects.toThrow(Error);
});
});
describe('#syncTemplates', () => {
it('sends correct API call when sync is successful', async () => {
axios.post.mockResolvedValue({
data: { message: 'Template sync initiated successfully' },
});
await actions.syncTemplates({ commit }, 123);
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/inboxes/123/sync_templates'
);
});
it('throws error when API call fails', async () => {
const errorMessage =
'Template sync is only available for WhatsApp channels';
axios.post.mockRejectedValue(new Error(errorMessage));
await expect(actions.syncTemplates({ commit }, 123)).rejects.toThrow(
errorMessage
);
});
});
});
@@ -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',
},
];
@@ -1,5 +1,6 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -26,7 +27,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 +44,7 @@ describe('#getters', () => {
widget_color: null,
website_token: null,
enable_auto_assignment: true,
instagram_id: 123456789,
});
});
@@ -64,4 +66,297 @@ 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',
});
});
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };
expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
});
it('returns empty array when templates is null or undefined', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: { message_templates: undefined },
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('returns empty array when templates is not an array', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: 'invalid',
additional_attributes: {},
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out templates without required properties', () => {
const invalidTemplates = [
{ name: 'incomplete_template' }, // missing status and components
{ status: 'approved' }, // missing name and components
{ name: 'another_incomplete', status: 'approved' }, // missing components
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: invalidTemplates,
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out non-approved templates', () => {
const mixedStatusTemplates = [
{
name: 'pending_template',
status: 'pending',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'rejected_template',
status: 'rejected',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'approved_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Test' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: mixedStatusTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('approved_template');
});
it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
const interactiveTemplates = [
{
name: 'list_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Choose an option' },
{ type: 'LIST', sections: [] },
],
},
{
name: 'product_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Product info' },
{ type: 'PRODUCT', catalog_id: '123' },
],
},
{
name: 'catalog_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Catalog' },
{ type: 'CATALOG', thumbnail_product_retailer_id: '123' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Regular message' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: interactiveTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('filters out location templates', () => {
const locationTemplates = [
{
name: 'location_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'LOCATION' },
{ type: 'BODY', text: 'Location message' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
{ type: 'BODY', text: 'Regular message' },
],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: locationTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('returns valid templates from fixture data', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: templates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
// All templates in fixtures should be approved and valid
expect(result.length).toBeGreaterThan(0);
// Verify all returned templates are approved
result.forEach(template => {
expect(template.status).toBe('approved');
expect(template.components).toBeDefined();
expect(Array.isArray(template.components)).toBe(true);
});
// Verify specific templates from fixtures are included
const templateNames = result.map(t => t.name);
expect(templateNames).toContain('sample_flight_confirmation');
expect(templateNames).toContain('sample_issue_resolution');
expect(templateNames).toContain('sample_shipping_confirmation');
expect(templateNames).toContain('no_variable_template');
expect(templateNames).toContain('order_confirmation');
});
it('prioritizes message_templates over additional_attributes.message_templates', () => {
const primaryTemplates = [
{
name: 'primary_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Primary' }],
},
];
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: primaryTemplates,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('primary_template');
});
it('falls back to additional_attributes.message_templates when message_templates is null', () => {
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('fallback_template');
});
});
});
@@ -0,0 +1,544 @@
export const templates = [
{
name: 'sample_flight_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'pt_BR',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{ type: 'HEADER', format: 'DOCUMENT' },
{
text: 'Esta é a sua confirmação de voo para {{1}}-{{2}} em {{3}}.',
type: 'BODY',
},
{
text: 'Esta mensagem é de uma empresa não verificada.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_issue_resolution',
status: 'approved',
category: 'ISSUE_RESOLUTION',
language: 'pt_BR',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Oi, {{1}}. Nós conseguimos resolver o problema que você estava enfrentando?',
type: 'BODY',
},
{
text: 'Esta mensagem é de uma empresa não verificada.',
type: 'FOOTER',
},
{
type: 'BUTTONS',
buttons: [
{ text: 'Sim', type: 'QUICK_REPLY' },
{ text: 'Não', type: 'QUICK_REPLY' },
],
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_issue_resolution',
status: 'approved',
category: 'ISSUE_RESOLUTION',
language: 'es',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hola, {{1}}. ¿Pudiste solucionar el problema que tenías?',
type: 'BODY',
},
{
text: 'Este mensaje proviene de un negocio no verificado.',
type: 'FOOTER',
},
{
type: 'BUTTONS',
buttons: [
{ text: 'Sí', type: 'QUICK_REPLY' },
{ text: 'No', type: 'QUICK_REPLY' },
],
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_issue_resolution',
status: 'approved',
category: 'ISSUE_RESOLUTION',
language: 'id',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Halo {{1}}, apakah kami bisa mengatasi masalah yang sedang Anda hadapi?',
type: 'BODY',
},
{
text: 'Pesan ini berasal dari bisnis yang tidak terverifikasi.',
type: 'FOOTER',
},
{
type: 'BUTTONS',
buttons: [
{ text: 'Ya', type: 'QUICK_REPLY' },
{ text: 'Tidak', type: 'QUICK_REPLY' },
],
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_shipping_confirmation',
status: 'approved',
category: 'SHIPPING_UPDATE',
language: 'pt_BR',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Seu pacote foi enviado. Ele será entregue em {{1}} dias úteis.',
type: 'BODY',
},
{
text: 'Esta mensagem é de uma empresa não verificada.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_shipping_confirmation',
status: 'approved',
category: 'SHIPPING_UPDATE',
language: 'id',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Paket Anda sudah dikirim. Paket akan sampai dalam {{1}} hari kerja.',
type: 'BODY',
},
{
text: 'Pesan ini berasal dari bisnis yang tidak terverifikasi.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_shipping_confirmation',
status: 'approved',
category: 'SHIPPING_UPDATE',
language: 'es',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'ó tu paquete. La entrega se realizará en {{1}} dí.',
type: 'BODY',
},
{
text: 'Este mensaje proviene de un negocio no verificado.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_flight_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'id',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{ type: 'HEADER', format: 'DOCUMENT' },
{
text: 'Ini merupakan konfirmasi penerbangan Anda untuk {{1}}-{{2}} di {{3}}.',
type: 'BODY',
},
{
text: 'Pesan ini berasal dari bisnis yang tidak terverifikasi.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_issue_resolution',
status: 'approved',
category: 'ISSUE_RESOLUTION',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hi {{1}}, were we able to solve the issue that you were facing?',
type: 'BODY',
},
{ text: 'This message is from an unverified business.', type: 'FOOTER' },
{
type: 'BUTTONS',
buttons: [
{ text: 'Yes', type: 'QUICK_REPLY' },
{ text: 'No', type: 'QUICK_REPLY' },
],
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_flight_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'es',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{ type: 'HEADER', format: 'DOCUMENT' },
{
text: 'Confirmamos tu vuelo a {{1}}-{{2}} para el {{3}}.',
type: 'BODY',
},
{
text: 'Este mensaje proviene de un negocio no verificado.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'sample_flight_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{ type: 'HEADER', format: 'DOCUMENT' },
{
text: 'This is your flight confirmation for {{1}}-{{2}} on {{3}}.',
type: 'BODY',
},
{ text: 'This message is from an unverified business.', type: 'FOOTER' },
],
rejected_reason: 'NONE',
},
{
name: 'sample_shipping_confirmation',
status: 'approved',
category: 'SHIPPING_UPDATE',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Your package has been shipped. It will be delivered in {{1}} business days.',
type: 'BODY',
},
{ text: 'This message is from an unverified business.', type: 'FOOTER' },
],
rejected_reason: 'NONE',
},
{
name: 'no_variable_template',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'pt_BR',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
},
{
text: 'This is a test whatsapp template',
type: 'BODY',
},
{
text: 'Esta mensagem é de uma empresa não verificada.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'order_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://example.com/shoes.jpg'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'technician_visit',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'event_invitation_static',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'purchase_receipt',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'discount_coupon',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'support_callback',
status: 'approved',
category: 'UTILITY',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+16506677566',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'training_video',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'VIDEO',
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'product_launch',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'greet',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'hello_world',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'feedback_request',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'address_update',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'delivery_confirmation',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
];
@@ -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
@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
getInboxReports: vi.fn(),
getAgentReports: vi.fn(),
getTeamReports: vi.fn(),
getLabelReports: vi.fn(),
},
}));
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
isFetchingLabelSummaryReports: false,
},
});
});
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [{ id: 1 }],
agentSummaryReports: [{ id: 2 }],
teamSummaryReports: [{ id: 3 }],
labelSummaryReports: [{ id: 4 }],
uiFlags: { isFetchingInboxSummaryReports: true },
};
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
});
it('should return label summary reports', () => {
expect(store.getters.getLabelSummaryReports(state)).toEqual([{ id: 4 }]);
});
it('should return UI flags', () => {
expect(store.getters.getUIFlags(state)).toEqual({
isFetchingInboxSummaryReports: true,
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
expect(state.teamSummaryReports).toEqual(data);
});
it('should set label summary report', () => {
const state = { ...initialState };
const data = [{ id: 4 }];
store.mutations.setLabelSummaryReport(state, data);
expect(state.labelSummaryReports).toEqual(data);
});
it('should merge UI flags with existing flags', () => {
const state = {
uiFlags: { flag1: true, flag2: false },
@@ -185,5 +201,29 @@ describe('Summary Reports Store', () => {
});
});
});
describe('fetchLabelSummaryReports', () => {
it('should fetch label reports successfully', async () => {
const params = { labelId: 789 };
const mockResponse = {
data: [{ label_id: 789, label_name: 'Test Label' }],
};
SummaryReportsAPI.getLabelReports.mockResolvedValue(mockResponse);
await store.actions.fetchLabelSummaryReports({ commit }, params);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingLabelSummaryReports: true,
});
expect(SummaryReportsAPI.getLabelReports).toHaveBeenCalledWith(params);
expect(commit).toHaveBeenCalledWith('setLabelSummaryReport', [
{ labelId: 789, labelName: 'Test Label' },
]);
expect(commit).toHaveBeenCalledWith('setUIFlags', {
isFetchingLabelSummaryReports: false,
});
});
});
});
});
@@ -17,6 +17,11 @@ const typeMap = {
apiMethod: 'getTeamReports',
mutationKey: 'setTeamSummaryReport',
},
label: {
flagKey: 'isFetchingLabelSummaryReports',
apiMethod: 'getLabelReports',
mutationKey: 'setLabelSummaryReport',
},
};
async function fetchSummaryReports(type, params, { commit }) {
@@ -38,10 +43,12 @@ export const initialState = {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
isFetchingLabelSummaryReports: false,
},
};
@@ -55,6 +62,9 @@ export const getters = {
getTeamSummaryReports(state) {
return state.teamSummaryReports;
},
getLabelSummaryReports(state) {
return state.labelSummaryReports;
},
getUIFlags(state) {
return state.uiFlags;
},
@@ -72,6 +82,10 @@ export const actions = {
fetchTeamSummaryReports({ commit }, params) {
return fetchSummaryReports('team', params, { commit });
},
fetchLabelSummaryReports({ commit }, params) {
return fetchSummaryReports('label', params, { commit });
},
};
export const mutations = {
@@ -84,6 +98,9 @@ export const mutations = {
setTeamSummaryReport(state, data) {
state.teamSummaryReports = data;
},
setLabelSummaryReport(state, data) {
state.labelSummaryReports = data;
},
setUIFlags(state, uiFlag) {
state.uiFlags = { ...state.uiFlags, ...uiFlag };
},