Restructure omni services and add Chatwoot research snapshot

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

View File

@@ -0,0 +1,7 @@
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainAssistant',
API: CaptainAssistantAPI,
});

View File

@@ -0,0 +1,56 @@
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainBulkAction',
API: CaptainBulkActionsAPI,
actions: mutations => ({
processBulkAction: async function processBulkAction(
{ commit },
{ type, actionType, ids }
) {
commit(mutations.SET_UI_FLAG, { isUpdating: true });
try {
const response = await CaptainBulkActionsAPI.create({
type: type,
ids,
fields: { status: actionType },
});
commit(mutations.SET_UI_FLAG, { isUpdating: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { isUpdating: false });
return throwErrorMessage(error);
}
},
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
actionType: 'delete',
ids,
});
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
return response;
},
handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
actionType: 'approve',
ids,
});
// Update response store after successful API call
await dispatch('captainResponses/updateBulkResponses', response, {
root: true,
});
return response;
},
}),
});

View File

@@ -0,0 +1,19 @@
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotMessages',
API: CopilotMessagesAPI,
getters: {
getMessagesByThreadId: state => copilotThreadId => {
return state.records
.filter(record => record.copilot_thread?.id === Number(copilotThreadId))
.sort((a, b) => a.id - b.id);
},
},
actions: mutationTypes => ({
upsert({ commit }, data) {
commit(mutationTypes.UPSERT, data);
},
}),
});

View File

@@ -0,0 +1,7 @@
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CopilotThreads',
API: CopilotThreadsAPI,
});

View File

@@ -0,0 +1,35 @@
import CaptainCustomTools from 'dashboard/api/captain/customTools';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainCustomTool',
API: CaptainCustomTools,
actions: mutations => ({
update: async ({ commit }, { id, ...updateObj }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainCustomTools.update(id, updateObj);
commit(mutations.EDIT, response.data);
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
delete: async ({ commit }, id) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainCustomTools.delete(id);
commit(mutations.DELETE, id);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return id;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});

View File

@@ -0,0 +1,7 @@
import CaptainDocumentAPI from 'dashboard/api/captain/document';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
});

View File

@@ -0,0 +1,22 @@
import CaptainInboxes from 'dashboard/api/captain/inboxes';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainInbox',
API: CaptainInboxes,
actions: mutations => ({
delete: async function remove({ commit }, { inboxId, assistantId }) {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainInboxes.delete({ inboxId, assistantId });
commit(mutations.DELETE, inboxId);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return inboxId;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});

View File

@@ -0,0 +1,71 @@
import { defineStore } from 'pinia';
import CaptainPreferencesAPI from 'dashboard/api/captain/preferences';
export const useCaptainConfigStore = defineStore('captainConfig', {
state: () => ({
providers: {},
models: {},
features: {},
uiFlags: {
isFetching: false,
},
}),
getters: {
getProviders: state => state.providers,
getModels: state => state.models,
getFeatures: state => state.features,
getUIFlags: state => state.uiFlags,
getModelsForFeature: state => featureKey => {
const feature = state.features[featureKey];
const models = feature?.models || [];
const providerOrder = { openai: 0, anthropic: 1, gemini: 2 };
return [...models].sort((a, b) => {
// Move coming_soon items to the end
if (a.coming_soon && !b.coming_soon) return 1;
if (!a.coming_soon && b.coming_soon) return -1;
// Sort by provider
const providerA = providerOrder[a.provider] ?? 999;
const providerB = providerOrder[b.provider] ?? 999;
if (providerA !== providerB) return providerA - providerB;
// Sort by credit_multiplier (highest first)
return (b.credit_multiplier || 0) - (a.credit_multiplier || 0);
});
},
getDefaultModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.default || null;
},
getSelectedModelForFeature: state => featureKey => {
const feature = state.features[featureKey];
return feature?.selected || feature?.default || null;
},
},
actions: {
async fetch() {
this.uiFlags.isFetching = true;
try {
const response = await CaptainPreferencesAPI.get();
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
} catch (error) {
// Ignore error
} finally {
this.uiFlags.isFetching = false;
}
},
async updatePreferences(data) {
const response = await CaptainPreferencesAPI.updatePreferences(data);
this.providers = response.data.providers || {};
this.models = response.data.models || {};
this.features = response.data.features || {};
},
},
});

View File

@@ -0,0 +1,58 @@
import CaptainResponseAPI from 'dashboard/api/captain/response';
import { createStore } from '../storeFactory';
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
export default createStore({
name: 'CaptainResponse',
API: CaptainResponseAPI,
getters: {
getPendingCount: state => state.meta.pendingCount || 0,
},
mutations: {
[SET_PENDING_COUNT](state, count) {
state.meta = {
...state.meta,
pendingCount: Number(count),
};
},
},
actions: mutations => ({
removeBulkResponses: ({ commit, state }, ids) => {
const updatedRecords = state.records.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, updatedRecords);
},
updateBulkResponses: ({ commit, state }, approvedResponses) => {
// Create a map of updated responses for faster lookup
const updatedResponsesMap = approvedResponses.reduce((map, response) => {
map[response.id] = response;
return map;
}, {});
// Update existing records with updated data
const updatedRecords = state.records.map(record => {
if (updatedResponsesMap[record.id]) {
return updatedResponsesMap[record.id]; // Replace with the updated response
}
return record;
});
commit(mutations.SET, updatedRecords);
},
fetchPendingCount: async ({ commit }, assistantId) => {
try {
const response = await CaptainResponseAPI.get({
status: 'pending',
page: 1,
assistantId,
});
const count = response.data?.meta?.total_count || 0;
commit(SET_PENDING_COUNT, count);
} catch (error) {
commit(SET_PENDING_COUNT, 0);
}
},
}),
});

View File

@@ -0,0 +1,38 @@
import CaptainScenarios from 'dashboard/api/captain/scenarios';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainScenario',
API: CaptainScenarios,
actions: mutations => ({
update: async ({ commit }, { id, assistantId, ...updateObj }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainScenarios.update(
{ id, assistantId },
updateObj
);
commit(mutations.EDIT, response.data);
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
delete: async ({ commit }, { id, assistantId }) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainScenarios.delete({ id, assistantId });
commit(mutations.DELETE, id);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return id;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});

View File

@@ -0,0 +1,24 @@
import { createStore } from '../storeFactory';
import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'Tools',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {
commit(mutations.SET_UI_FLAG, { fetchingList: true });
try {
const response = await CaptainToolsAPI.get();
commit(mutations.SET, response.data);
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { fetchingList: false });
return throwErrorMessage(error);
}
},
}),
});
export default toolsStore;

View File

@@ -0,0 +1,5 @@
export const STATUS = {
FAILED: 'failed',
FETCHING: 'fetching',
FINISHED: 'finished',
};

View File

@@ -0,0 +1,126 @@
import { createStore } from 'vuex';
import accounts from './modules/accounts';
import agentBots from './modules/agentBots';
import agentCapacityPolicies from './modules/agentCapacityPolicies';
import agents from './modules/agents';
import assignmentPolicies from './modules/assignmentPolicies';
import articles from './modules/helpCenterArticles';
import attributes from './modules/attributes';
import auditlogs from './modules/auditlogs';
import auth from './modules/auth';
import automations from './modules/automations';
import bulkActions from './modules/bulkActions';
import campaigns from './modules/campaigns';
import cannedResponse from './modules/cannedResponse';
import categories from './modules/helpCenterCategories';
import contactConversations from './modules/contactConversations';
import contactLabels from './modules/contactLabels';
import contactNotes from './modules/contactNotes';
import contacts from './modules/contacts';
import conversationLabels from './modules/conversationLabels';
import conversationMetadata from './modules/conversationMetadata';
import conversationPage from './modules/conversationPage';
import conversations from './modules/conversations';
import conversationSearch from './modules/conversationSearch';
import conversationStats from './modules/conversationStats';
import conversationTypingStatus from './modules/conversationTypingStatus';
import conversationWatchers from './modules/conversationWatchers';
import csat from './modules/csat';
import customRole from './modules/customRole';
import customViews from './modules/customViews';
import dashboardApps from './modules/dashboardApps';
import draftMessages from './modules/draftMessages';
import globalConfig from 'shared/store/globalConfig';
import inboxAssignableAgents from './modules/inboxAssignableAgents';
import inboxes from './modules/inboxes';
import inboxMembers from './modules/inboxMembers';
import integrations from './modules/integrations';
import labels from './modules/labels';
import macros from './modules/macros';
import notifications from './modules/notifications';
import portals from './modules/helpCenterPortals';
import reports from './modules/reports';
import sla from './modules/sla';
import slaReports from './modules/SLAReports';
import summaryReports from './modules/summaryReports';
import teamMembers from './modules/teamMembers';
import teams from './modules/teams';
import userNotificationSettings from './modules/userNotificationSettings';
import webhooks from './modules/webhooks';
import captainAssistants from './captain/assistant';
import captainDocuments from './captain/document';
import captainResponses from './captain/response';
import captainInboxes from './captain/inboxes';
import captainBulkActions from './captain/bulkActions';
import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
import captainCustomTools from './captain/customTools';
const plugins = [];
export default createStore({
modules: {
accounts,
agentBots,
agentCapacityPolicies,
agents,
assignmentPolicies,
articles,
attributes,
auditlogs,
auth,
automations,
bulkActions,
campaigns,
cannedResponse,
categories,
contactConversations,
contactLabels,
contactNotes,
contacts,
conversationLabels,
conversationMetadata,
conversationPage,
conversations,
conversationSearch,
conversationStats,
conversationTypingStatus,
conversationWatchers,
csat,
customRole,
customViews,
dashboardApps,
draftMessages,
globalConfig,
inboxAssignableAgents,
inboxes,
inboxMembers,
integrations,
labels,
macros,
notifications,
portals,
reports,
sla,
slaReports,
summaryReports,
teamMembers,
teams,
userNotificationSettings,
webhooks,
captainAssistants,
captainDocuments,
captainResponses,
captainInboxes,
captainBulkActions,
copilotThreads,
copilotMessages,
captainScenarios,
captainTools,
captainCustomTools,
},
plugins,
});

View File

@@ -0,0 +1,107 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import SLAReportsAPI from '../../api/slaReports';
import { downloadCsvFile } from 'dashboard/helper/downloadHelper';
export const state = {
records: [],
metrics: {
numberOfConversations: 0,
numberOfSLAMisses: 0,
hitRate: '0%',
},
uiFlags: {
isFetching: false,
isFetchingMetrics: false,
},
meta: {
count: 0,
currentPage: 1,
},
};
export const getters = {
getAll(_state) {
return _state.records;
},
getMeta(_state) {
return _state.meta;
},
getMetrics(_state) {
return _state.metrics;
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
get: async function getResponses({ commit }, params) {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: true });
try {
const response = await SLAReportsAPI.get(params);
const { payload, meta } = response.data;
commit(types.SET_SLA_REPORTS, payload);
commit(types.SET_SLA_REPORTS_META, meta);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetching: false });
}
},
getMetrics: async function getMetrics({ commit }, params) {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: true });
try {
const response = await SLAReportsAPI.getMetrics(params);
commit(types.SET_SLA_REPORTS_METRICS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_SLA_REPORTS_UI_FLAG, { isFetchingMetrics: false });
}
},
download(_, params) {
return SLAReportsAPI.download(params).then(response => {
downloadCsvFile(params.fileName, response.data);
});
},
};
export const mutations = {
[types.SET_SLA_REPORTS_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_SLA_REPORTS]: MutationHelpers.set,
[types.SET_SLA_REPORTS_METRICS](
_state,
{
number_of_sla_misses: numberOfSLAMisses,
hit_rate: hitRate,
total_applied_slas: numberOfConversations,
}
) {
_state.metrics = {
numberOfSLAMisses,
hitRate,
numberOfConversations,
};
},
[types.SET_SLA_REPORTS_META](_state, { count, current_page: currentPage }) {
_state.meta = {
count,
currentPage,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,179 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AccountAPI from '../../api/account';
import { differenceInDays } from 'date-fns';
import EnterpriseAccountAPI from '../../api/enterprise/account';
import { throwErrorMessage } from '../utils/api';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
const findRecordById = ($state, id) =>
$state.records.find(record => record.id === Number(id)) || {};
const TRIAL_PERIOD_DAYS = 15;
const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isCheckoutInProcess: false,
isFetchingLimits: false,
},
};
export const getters = {
getAccount: $state => id => {
return findRecordById($state, id);
},
getUIFlags($state) {
return $state.uiFlags;
},
isRTL: ($state, _getters, rootState, rootGetters) => {
const accountId = Number(rootState.route?.params?.accountId);
const userLocale = rootGetters?.getUISettings?.locale;
const accountLocale =
accountId && findRecordById($state, accountId)?.locale;
// Prefer user locale; fallback to account locale
const effectiveLocale = userLocale ?? accountLocale;
return effectiveLocale ? getLanguageDirection(effectiveLocale) : false;
},
isTrialAccount: $state => id => {
const account = findRecordById($state, id);
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= TRIAL_PERIOD_DAYS;
},
isFeatureEnabledonAccount: $state => (id, featureName) => {
const { features = {} } = findRecordById($state, id);
return features[featureName] || false;
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true });
try {
const response = await AccountAPI.get();
commit(types.default.ADD_ACCOUNT, response.data);
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, {
isFetchingItem: false,
});
}
},
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.delete(id);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
toggleDeletion: async (
{ commit },
{ action_type } = { action_type: 'delete' }
) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true });
try {
await EnterpriseAccountAPI.toggleDeletion(action_type);
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
create: async ({ commit }, accountInfo) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true });
try {
const response = await AccountAPI.createAccount(accountInfo);
const account_id = response.data.data.account_id;
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: false });
return account_id;
} catch (error) {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCreating: false });
throw error;
}
},
checkout: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
const response = await EnterpriseAccountAPI.checkout();
window.location = response.data.redirect_url;
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
subscription: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: true });
try {
await EnterpriseAccountAPI.subscription();
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isCheckoutInProcess: false });
}
},
limits: async ({ commit }) => {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: true });
try {
const response = await EnterpriseAccountAPI.getLimits();
commit(types.default.SET_ACCOUNT_LIMITS, response.data);
} catch (error) {
// silent error
} finally {
commit(types.default.SET_ACCOUNT_UI_FLAG, { isFetchingLimits: false });
}
},
getCacheKeys: async () => {
return AccountAPI.getCacheKeys();
},
};
export const mutations = {
[types.default.SET_ACCOUNT_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.default.ADD_ACCOUNT]: MutationHelpers.setSingleRecord,
[types.default.EDIT_ACCOUNT]: MutationHelpers.update,
[types.default.SET_ACCOUNT_LIMITS]: MutationHelpers.updateAttributes,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,219 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import AgentBotsAPI from '../../api/agentBots';
import InboxesAPI from '../../api/inboxes';
import { throwErrorMessage } from '../utils/api';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isDeleting: false,
isUpdating: false,
isUpdatingAvatar: false,
isFetchingAgentBot: false,
isSettingAgentBot: false,
isDisconnecting: false,
},
agentBotInbox: {},
};
export const getters = {
getBots($state) {
return $state.records;
},
getUIFlags($state) {
return $state.uiFlags;
},
getBot: $state => botId => {
const [bot] = $state.records.filter(record => record.id === Number(botId));
return bot || {};
},
getActiveAgentBot: $state => inboxId => {
const associatedAgentBotId = $state.agentBotInbox[Number(inboxId)];
return getters.getBot($state)(associatedAgentBotId);
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: true });
try {
const response = await AgentBotsAPI.get();
commit(types.SET_AGENT_BOTS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit }, botData) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: true });
try {
// 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) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isCreating: false });
}
return null;
},
update: async ({ commit }, { id, data }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true });
try {
// 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);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true });
try {
await AgentBotsAPI.delete(id);
commit(types.DELETE_AGENT_BOT, id);
} catch (error) {
throwErrorMessage(error);
} finally {
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 {
const { data } = await AgentBotsAPI.show(id);
commit(types.ADD_AGENT_BOT, data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingItem: false });
}
},
fetchAgentBotInbox: async ({ commit }, inboxId) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: true });
try {
const { data } = await InboxesAPI.getAgentBot(inboxId);
const { agent_bot: agentBot = {} } = data || {};
commit(types.SET_AGENT_BOT_INBOX, { agentBotId: agentBot.id, inboxId });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: false });
}
},
setAgentBotInbox: async ({ commit }, { inboxId, botId }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: true });
try {
await InboxesAPI.setAgentBot(inboxId, botId);
commit(types.SET_AGENT_BOT_INBOX, { agentBotId: botId, inboxId });
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: false });
}
},
disconnectBot: async ({ commit }, { inboxId }) => {
commit(types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: true });
try {
await InboxesAPI.setAgentBot(inboxId, null);
commit(types.SET_AGENT_BOT_INBOX, { agentBotId: '', inboxId });
} catch (error) {
throwErrorMessage(error);
} finally {
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 = {
[types.SET_AGENT_BOT_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.ADD_AGENT_BOT]: MutationHelpers.setSingleRecord,
[types.SET_AGENT_BOTS]: MutationHelpers.set,
[types.EDIT_AGENT_BOT]: MutationHelpers.update,
[types.DELETE_AGENT_BOT]: MutationHelpers.destroy,
[types.SET_AGENT_BOT_INBOX]($state, { agentBotId, inboxId }) {
$state.agentBotInbox = {
...$state.agentBotInbox,
[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 {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,316 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import AgentCapacityPoliciesAPI from '../../api/agentCapacityPolicies';
import { throwErrorMessage } from '../utils/api';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
usersUiFlags: {
isFetching: false,
isDeleting: false,
},
};
export const getters = {
getAgentCapacityPolicies(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getUsersUIFlags(_state) {
return _state.usersUiFlags;
},
getAgentCapacityPolicyById: _state => id => {
return _state.records.find(record => record.id === Number(id)) || {};
},
};
export const actions = {
get: async function get({ commit }) {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: true });
try {
const response = await AgentCapacityPoliciesAPI.get();
commit(
types.SET_AGENT_CAPACITY_POLICIES,
camelcaseKeys(response.data, { deep: true })
);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetching: false });
}
},
show: async function show({ commit }, policyId) {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isFetchingItem: true });
try {
const response = await AgentCapacityPoliciesAPI.show(policyId);
const policy = camelcaseKeys(response.data, { deep: true });
commit(types.SET_AGENT_CAPACITY_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, {
isFetchingItem: false,
});
}
},
create: async function create({ commit }, policyObj) {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: true });
try {
const response = await AgentCapacityPoliciesAPI.create(
snakecaseKeys(policyObj)
);
commit(
types.ADD_AGENT_CAPACITY_POLICY,
camelcaseKeys(response.data, { deep: true })
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isCreating: false });
}
},
update: async function update({ commit }, { id, ...policyParams }) {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: true });
try {
const response = await AgentCapacityPoliciesAPI.update(
id,
snakecaseKeys(policyParams)
);
commit(
types.EDIT_AGENT_CAPACITY_POLICY,
camelcaseKeys(response.data, { deep: true })
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isUpdating: false });
}
},
delete: async function deletePolicy({ commit }, policyId) {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: true });
try {
await AgentCapacityPoliciesAPI.delete(policyId);
commit(types.DELETE_AGENT_CAPACITY_POLICY, policyId);
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG, { isDeleting: false });
}
},
getUsers: async function getUsers({ commit }, policyId) {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isFetching: true,
});
try {
const response = await AgentCapacityPoliciesAPI.getUsers(policyId);
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS, {
policyId,
users: camelcaseKeys(response.data),
});
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isFetching: false,
});
}
},
addUser: async function addUser({ commit }, { policyId, userData }) {
try {
const response = await AgentCapacityPoliciesAPI.addUser(
policyId,
userData
);
commit(types.ADD_AGENT_CAPACITY_POLICIES_USERS, {
policyId,
user: camelcaseKeys(response.data),
});
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
removeUser: async function removeUser({ commit }, { policyId, userId }) {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isDeleting: true,
});
try {
await AgentCapacityPoliciesAPI.removeUser(policyId, userId);
commit(types.DELETE_AGENT_CAPACITY_POLICIES_USERS, { policyId, userId });
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG, {
isDeleting: false,
});
}
},
createInboxLimit: async function createInboxLimit(
{ commit },
{ policyId, limitData }
) {
try {
const response = await AgentCapacityPoliciesAPI.createInboxLimit(
policyId,
limitData
);
commit(
types.SET_AGENT_CAPACITY_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
updateInboxLimit: async function updateInboxLimit(
{ commit },
{ policyId, limitId, limitData }
) {
try {
const response = await AgentCapacityPoliciesAPI.updateInboxLimit(
policyId,
limitId,
limitData
);
commit(
types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
deleteInboxLimit: async function deleteInboxLimit(
{ commit },
{ policyId, limitId }
) {
try {
await AgentCapacityPoliciesAPI.deleteInboxLimit(policyId, limitId);
commit(types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES, {
policyId,
limitId,
});
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
};
export const mutations = {
[types.SET_AGENT_CAPACITY_POLICIES_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_AGENT_CAPACITY_POLICIES]: MutationHelpers.set,
[types.SET_AGENT_CAPACITY_POLICY]: MutationHelpers.setSingleRecord,
[types.ADD_AGENT_CAPACITY_POLICY]: MutationHelpers.create,
[types.EDIT_AGENT_CAPACITY_POLICY]: MutationHelpers.updateAttributes,
[types.DELETE_AGENT_CAPACITY_POLICY]: MutationHelpers.destroy,
[types.SET_AGENT_CAPACITY_POLICIES_USERS_UI_FLAG](_state, data) {
_state.usersUiFlags = {
..._state.usersUiFlags,
...data,
};
},
[types.SET_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, users }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.users = users;
}
},
[types.ADD_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, user }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.users = policy.users || [];
policy.users.push(user);
policy.assignedAgentCount = policy.users.length;
}
},
[types.DELETE_AGENT_CAPACITY_POLICIES_USERS](_state, { policyId, userId }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.users = (policy.users || []).filter(user => user.id !== userId);
policy.assignedAgentCount = policy.users.length;
}
},
[types.SET_AGENT_CAPACITY_POLICIES_INBOXES](_state, data) {
const policy = _state.records.find(
p => p.id === data.agentCapacityPolicyId
);
policy?.inboxCapacityLimits.push({
id: data.id,
inboxId: data.inboxId,
conversationLimit: data.conversationLimit,
});
},
[types.EDIT_AGENT_CAPACITY_POLICIES_INBOXES](_state, data) {
const policy = _state.records.find(
p => p.id === data.agentCapacityPolicyId
);
const limit = policy?.inboxCapacityLimits.find(l => l.id === data.id);
if (limit) {
Object.assign(limit, {
conversationLimit: data.conversationLimit,
});
}
},
[types.DELETE_AGENT_CAPACITY_POLICIES_INBOXES](
_state,
{ policyId, limitId }
) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxCapacityLimits = policy.inboxCapacityLimits.filter(
limit => limit.id !== limitId
);
}
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,133 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AgentAPI from '../../api/agents';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getAgents($state) {
return $state.records;
},
getVerifiedAgents($state) {
return $state.records.filter(record => record.confirmed);
},
getUIFlags($state) {
return $state.uiFlags;
},
getAgentById: $state => id => {
return $state.records.find(record => record.id === Number(id)) || {};
},
getAgentStatus($state) {
let status = {
online: $state.records.filter(
agent => agent.availability_status === 'online'
).length,
busy: $state.records.filter(agent => agent.availability_status === 'busy')
.length,
offline: $state.records.filter(
agent => agent.availability_status === 'offline'
).length,
};
return status;
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_AGENT_FETCHING_STATUS, true);
try {
const response = await AgentAPI.get();
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
commit(types.default.SET_AGENTS, response.data);
} catch (error) {
commit(types.default.SET_AGENT_FETCHING_STATUS, false);
}
},
create: async ({ commit }, agentInfo) => {
commit(types.default.SET_AGENT_CREATING_STATUS, true);
try {
const response = await AgentAPI.create(agentInfo);
commit(types.default.ADD_AGENT, response.data);
commit(types.default.SET_AGENT_CREATING_STATUS, false);
} catch (error) {
commit(types.default.SET_AGENT_CREATING_STATUS, false);
throw error;
}
},
update: async ({ commit }, { id, ...agentParams }) => {
commit(types.default.SET_AGENT_UPDATING_STATUS, true);
try {
const response = await AgentAPI.update(id, agentParams);
commit(types.default.EDIT_AGENT, response.data);
commit(types.default.SET_AGENT_UPDATING_STATUS, false);
} catch (error) {
commit(types.default.SET_AGENT_UPDATING_STATUS, false);
throw new Error(error);
}
},
updateSingleAgentPresence: ({ commit }, { id, availabilityStatus }) => {
commit(types.default.UPDATE_SINGLE_AGENT_PRESENCE, {
id,
availabilityStatus,
});
},
updatePresence: async ({ commit }, data) => {
commit(types.default.UPDATE_AGENTS_PRESENCE, data);
},
delete: async ({ commit }, agentId) => {
commit(types.default.SET_AGENT_DELETING_STATUS, true);
try {
await AgentAPI.delete(agentId);
commit(types.default.DELETE_AGENT, agentId);
commit(types.default.SET_AGENT_DELETING_STATUS, false);
} catch (error) {
commit(types.default.SET_AGENT_DELETING_STATUS, false);
throw new Error(error);
}
},
};
export const mutations = {
[types.default.SET_AGENT_FETCHING_STATUS]($state, status) {
$state.uiFlags.isFetching = status;
},
[types.default.SET_AGENT_CREATING_STATUS]($state, status) {
$state.uiFlags.isCreating = status;
},
[types.default.SET_AGENT_UPDATING_STATUS]($state, status) {
$state.uiFlags.isUpdating = status;
},
[types.default.SET_AGENT_DELETING_STATUS]($state, status) {
$state.uiFlags.isDeleting = status;
},
[types.default.SET_AGENTS]: MutationHelpers.set,
[types.default.ADD_AGENT]: MutationHelpers.create,
[types.default.EDIT_AGENT]: MutationHelpers.update,
[types.default.DELETE_AGENT]: MutationHelpers.destroy,
[types.default.UPDATE_AGENTS_PRESENCE]: MutationHelpers.updatePresence,
[types.default.UPDATE_SINGLE_AGENT_PRESENCE]: (
$state,
{ id, availabilityStatus }
) =>
MutationHelpers.updateSingleRecordPresence($state.records, {
id,
availabilityStatus,
}),
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,232 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import AssignmentPoliciesAPI from '../../api/assignmentPolicies';
import { throwErrorMessage } from '../utils/api';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
inboxUiFlags: {
isFetching: false,
isDeleting: false,
},
};
export const getters = {
getAssignmentPolicies(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getInboxUiFlags(_state) {
return _state.inboxUiFlags;
},
getAssignmentPolicyById: _state => id => {
return _state.records.find(record => record.id === Number(id)) || {};
},
};
export const actions = {
get: async function get({ commit }) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: true });
try {
const response = await AssignmentPoliciesAPI.get();
commit(types.SET_ASSIGNMENT_POLICIES, camelcaseKeys(response.data));
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetching: false });
}
},
show: async function show({ commit }, policyId) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: true });
try {
const response = await AssignmentPoliciesAPI.show(policyId);
const policy = camelcaseKeys(response.data);
commit(types.SET_ASSIGNMENT_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isFetchingItem: false });
}
},
create: async function create({ commit }, policyObj) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: true });
try {
const response = await AssignmentPoliciesAPI.create(
snakecaseKeys(policyObj)
);
commit(types.ADD_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isCreating: false });
}
},
update: async function update({ commit }, { id, ...policyParams }) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: true });
try {
const response = await AssignmentPoliciesAPI.update(
id,
snakecaseKeys(policyParams)
);
commit(types.EDIT_ASSIGNMENT_POLICY, camelcaseKeys(response.data));
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isUpdating: false });
}
},
delete: async function deletePolicy({ commit }, policyId) {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: true });
try {
await AssignmentPoliciesAPI.delete(policyId);
commit(types.DELETE_ASSIGNMENT_POLICY, policyId);
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_UI_FLAG, { isDeleting: false });
}
},
getInboxes: async function getInboxes({ commit }, policyId) {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, { isFetching: true });
try {
const response = await AssignmentPoliciesAPI.getInboxes(policyId);
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES, {
policyId,
inboxes: camelcaseKeys(response.data.inboxes),
});
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
isFetching: false,
});
}
},
setInboxPolicy: async function setInboxPolicy(
{ commit },
{ inboxId, policyId }
) {
try {
const response = await AssignmentPoliciesAPI.setInboxPolicy(
inboxId,
policyId
);
commit(
types.ADD_ASSIGNMENT_POLICIES_INBOXES,
camelcaseKeys(response.data)
);
return response.data;
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
getInboxPolicy: async function getInboxPolicy(_, { inboxId }) {
try {
const response = await AssignmentPoliciesAPI.getInboxPolicy(inboxId);
return camelcaseKeys(response.data);
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
updateInboxPolicy: async function updateInboxPolicy({ commit }, { policy }) {
try {
commit(types.EDIT_ASSIGNMENT_POLICY, policy);
} catch (error) {
throwErrorMessage(error);
throw error;
}
},
removeInboxPolicy: async function removeInboxPolicy(
{ commit },
{ policyId, inboxId }
) {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
isDeleting: true,
});
try {
await AssignmentPoliciesAPI.removeInboxPolicy(inboxId);
commit(types.DELETE_ASSIGNMENT_POLICIES_INBOXES, {
policyId,
inboxId,
});
} catch (error) {
throwErrorMessage(error);
throw error;
} finally {
commit(types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG, {
isDeleting: false,
});
}
},
};
export const mutations = {
[types.SET_ASSIGNMENT_POLICIES_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_ASSIGNMENT_POLICIES]: MutationHelpers.set,
[types.SET_ASSIGNMENT_POLICY]: MutationHelpers.setSingleRecord,
[types.ADD_ASSIGNMENT_POLICY]: MutationHelpers.create,
[types.EDIT_ASSIGNMENT_POLICY]: MutationHelpers.updateAttributes,
[types.DELETE_ASSIGNMENT_POLICY]: MutationHelpers.destroy,
[types.SET_ASSIGNMENT_POLICIES_INBOXES_UI_FLAG](_state, data) {
_state.inboxUiFlags = {
..._state.inboxUiFlags,
...data,
};
},
[types.SET_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxes }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxes = inboxes;
}
},
[types.DELETE_ASSIGNMENT_POLICIES_INBOXES](_state, { policyId, inboxId }) {
const policy = _state.records.find(p => p.id === policyId);
if (policy) {
policy.inboxes = policy?.inboxes?.filter(inbox => inbox.id !== inboxId);
}
},
[types.ADD_ASSIGNMENT_POLICIES_INBOXES]: MutationHelpers.updateAttributes,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,109 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import AttributeAPI from '../../api/attributes';
import camelcaseKeys from 'camelcase-keys';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getAttributes: _state => {
return _state.records;
},
getConversationAttributes: _state => {
return _state.records
.filter(record => record.attribute_model === 'conversation_attribute')
.map(camelcaseKeys);
},
getContactAttributes: _state => {
return _state.records
.filter(record => record.attribute_model === 'contact_attribute')
.map(camelcaseKeys);
},
getAttributesByModel: _state => attributeModel => {
return _state.records.filter(
record => record.attribute_model === attributeModel
);
},
};
export const actions = {
get: async function getAttributesByModel({ commit }) {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: true });
try {
const response = await AttributeAPI.getAttributesByModel();
commit(types.SET_CUSTOM_ATTRIBUTE, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isFetching: false });
}
},
create: async function createAttribute({ commit }, attributeObj) {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: true });
try {
const response = await AttributeAPI.create(attributeObj);
commit(types.ADD_CUSTOM_ATTRIBUTE, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: true });
try {
const response = await AttributeAPI.update(id, updateObj);
commit(types.EDIT_CUSTOM_ATTRIBUTE, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: true });
try {
await AttributeAPI.delete(id);
commit(types.DELETE_CUSTOM_ATTRIBUTE, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CUSTOM_ATTRIBUTE_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_CUSTOM_ATTRIBUTE_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.ADD_CUSTOM_ATTRIBUTE]: MutationHelpers.create,
[types.SET_CUSTOM_ATTRIBUTE]: MutationHelpers.set,
[types.EDIT_CUSTOM_ATTRIBUTE]: MutationHelpers.update,
[types.DELETE_CUSTOM_ATTRIBUTE]: MutationHelpers.destroy,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,79 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import AuditLogsAPI from '../../api/auditLogs';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const state = {
records: [],
meta: {
currentPage: 1,
perPage: 15,
totalEntries: 0,
},
uiFlags: {
fetchingList: false,
},
};
const getters = {
getAuditLogs(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getMeta(_state) {
return _state.meta;
},
};
const actions = {
async fetch({ commit }, { page } = {}) {
commit(types.default.SET_AUDIT_LOGS_UI_FLAG, { fetchingList: true });
try {
const response = await AuditLogsAPI.get({ page });
const { audit_logs: logs = [] } = response.data;
const {
total_entries: totalEntries,
per_page: perPage,
current_page: currentPage,
} = response.data;
commit(types.default.SET_AUDIT_LOGS, logs);
commit(types.default.SET_AUDIT_LOGS_META, {
totalEntries,
perPage,
currentPage,
});
commit(types.default.SET_AUDIT_LOGS_UI_FLAG, { fetchingList: false });
return logs;
} catch (error) {
commit(types.default.SET_AUDIT_LOGS_UI_FLAG, { fetchingList: false });
return throwErrorMessage(error);
}
},
};
const mutations = {
[types.default.SET_AUDIT_LOGS_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.default.SET_AUDIT_LOGS]: MutationHelpers.set,
[types.default.SET_AUDIT_LOGS_META](_state, data) {
_state.meta = {
..._state.meta,
...data,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,298 @@
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: {
id: null,
account_id: null,
accounts: [],
email: null,
name: null,
},
uiFlags: {
isFetching: true,
},
};
// getters
export const getters = {
isLoggedIn($state) {
return !!$state.currentUser.id;
},
getCurrentUserID($state) {
return $state.currentUser.id;
},
getUISettings($state) {
return $state.currentUser.ui_settings || {};
},
getAuthUIFlags($state) {
return $state.uiFlags;
},
getCurrentUserAvailability($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount.availability;
},
getCurrentUserAutoOffline($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount.auto_offline;
},
getCurrentAccountId(_, __, rootState) {
if (rootState.route.params && rootState.route.params.accountId) {
return Number(rootState.route.params.accountId);
}
return null;
},
getCurrentRole($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount.role;
},
getCurrentCustomRoleId($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount.custom_role_id;
},
getCurrentUser($state) {
return $state.currentUser;
},
getMessageSignature($state) {
const { message_signature: messageSignature } = $state.currentUser;
return messageSignature || '';
},
getCurrentAccount($state, $getters) {
const { accounts = [] } = $state.currentUser;
const [currentAccount = {}] = accounts.filter(
account => account.id === $getters.getCurrentAccountId
);
return currentAccount || {};
},
getUserAccounts($state) {
const { accounts = [] } = $state.currentUser;
return accounts;
},
};
// actions
export const actions = {
async validityCheck(context) {
try {
const response = await authAPI.validityCheck();
const currentUser = response.data.payload.data;
setUser(currentUser);
context.commit(types.SET_CURRENT_USER, currentUser);
} catch (error) {
if (error?.response?.status === 401) {
clearCookiesOnLogout();
}
}
},
async setUser({ commit, dispatch }) {
if (authAPI.hasAuthCookie()) {
await dispatch('validityCheck');
} else {
commit(types.CLEAR_USER);
}
commit(types.SET_CURRENT_USER_UI_FLAGS, { isFetching: false });
},
logout({ commit }) {
commit(types.CLEAR_USER);
},
updateProfile: async ({ commit }, params) => {
// eslint-disable-next-line no-useless-catch
try {
const response = await authAPI.profileUpdate(params);
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
throw error;
}
},
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();
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
// Ignore error
}
},
updateUISettings: async ({ commit }, params) => {
try {
commit(types.SET_CURRENT_USER_UI_SETTINGS, params);
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
}
},
updateAvailability: async (
{ commit, dispatch, getters: _getters },
params
) => {
const previousStatus = _getters.getCurrentUserAvailability;
try {
// optimisticly update current status
commit(types.SET_CURRENT_USER_AVAILABILITY, params.availability);
const response = await authAPI.updateAvailability(params);
const userData = response.data;
const { id } = userData;
commit(types.SET_CURRENT_USER, response.data);
dispatch('agents/updateSingleAgentPresence', {
id,
availabilityStatus: params.availability,
});
} catch (error) {
// revert back to previous status if update fails
commit(types.SET_CURRENT_USER_AVAILABILITY, previousStatus);
}
},
updateAutoOffline: async (
{ commit, getters: _getters },
{ accountId, autoOffline }
) => {
const previousAutoOffline = _getters.getCurrentUserAutoOffline;
try {
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, autoOffline);
const response = await authAPI.updateAutoOffline(accountId, autoOffline);
commit(types.SET_CURRENT_USER, response.data);
} catch (error) {
commit(types.SET_CURRENT_USER_AUTO_OFFLINE, previousAutoOffline);
}
},
setCurrentUserAvailability({ commit, state: $state }, data) {
if (data[$state.currentUser.id]) {
commit(types.SET_CURRENT_USER_AVAILABILITY, data[$state.currentUser.id]);
}
},
setActiveAccount: async (_, { accountId }) => {
try {
await authAPI.setActiveAccount({ accountId });
} catch (error) {
// Ignore error
}
},
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();
} catch (error) {
// Ignore error
}
},
};
// mutations
export const mutations = {
[types.SET_CURRENT_USER_AVAILABILITY](_state, availability) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === _state.currentUser.account_id) {
return { ...account, availability, availability_status: availability };
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.SET_CURRENT_USER_AUTO_OFFLINE](_state, autoOffline) {
const accounts = _state.currentUser.accounts.map(account => {
if (account.id === _state.currentUser.account_id) {
return { ...account, autoOffline: autoOffline };
}
return account;
});
_state.currentUser = {
..._state.currentUser,
accounts,
};
},
[types.CLEAR_USER](_state) {
_state.currentUser = initialState.currentUser;
},
[types.SET_CURRENT_USER](_state, currentUser) {
_state.currentUser = currentUser;
},
[types.SET_CURRENT_USER_UI_SETTINGS](_state, { uiSettings }) {
_state.currentUser = {
..._state.currentUser,
ui_settings: {
..._state.currentUser.ui_settings,
...uiSettings,
},
};
},
[types.SET_CURRENT_USER_UI_FLAGS](_state, { isFetching }) {
_state.uiFlags = { isFetching };
},
};
export default {
state: initialState,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,105 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import AutomationAPI from '../../api/automation';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isDeleting: false,
isUpdating: false,
},
};
export const getters = {
getAutomations(_state) {
return _state.records.sort((a1, a2) => a1.id - a2.id);
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
get: async function getAutomations({ commit }) {
commit(types.SET_AUTOMATION_UI_FLAG, { isFetching: true });
try {
const response = await AutomationAPI.get();
commit(types.SET_AUTOMATIONS, response.data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_AUTOMATION_UI_FLAG, { isFetching: false });
}
},
create: async function createAutomation({ commit }, automationObj) {
commit(types.SET_AUTOMATION_UI_FLAG, { isCreating: true });
try {
const response = await AutomationAPI.create(automationObj);
commit(types.ADD_AUTOMATION, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_AUTOMATION_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.SET_AUTOMATION_UI_FLAG, { isUpdating: true });
try {
const response = await AutomationAPI.update(id, updateObj);
commit(types.EDIT_AUTOMATION, response.data.payload);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_AUTOMATION_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_AUTOMATION_UI_FLAG, { isDeleting: true });
try {
await AutomationAPI.delete(id);
commit(types.DELETE_AUTOMATION, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_AUTOMATION_UI_FLAG, { isDeleting: false });
}
},
clone: async ({ commit }, id) => {
commit(types.SET_AUTOMATION_UI_FLAG, { isCloning: true });
try {
await AutomationAPI.clone(id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_AUTOMATION_UI_FLAG, { isCloning: false });
}
},
uploadAttachment: async (_, file) => {
const { blobId } = await uploadFile(file);
return blobId;
},
};
export const mutations = {
[types.SET_AUTOMATION_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.ADD_AUTOMATION]: MutationHelpers.create,
[types.SET_AUTOMATIONS]: MutationHelpers.set,
[types.EDIT_AUTOMATION]: MutationHelpers.update,
[types.DELETE_AUTOMATION]: MutationHelpers.destroy,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,74 @@
import types from '../mutation-types';
import BulkActionsAPI from '../../api/bulkActions';
export const state = {
selectedConversationIds: [],
uiFlags: {
isUpdating: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getSelectedConversationIds(_state) {
return _state.selectedConversationIds;
},
};
export const actions = {
process: async function processAction({ commit }, payload) {
commit(types.SET_BULK_ACTIONS_FLAG, { isUpdating: true });
try {
await BulkActionsAPI.create(payload);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_BULK_ACTIONS_FLAG, { isUpdating: false });
}
},
setSelectedConversationIds({ commit }, id) {
commit(types.SET_SELECTED_CONVERSATION_IDS, id);
},
removeSelectedConversationIds({ commit }, id) {
commit(types.REMOVE_SELECTED_CONVERSATION_IDS, id);
},
clearSelectedConversationIds({ commit }) {
commit(types.CLEAR_SELECTED_CONVERSATION_IDS);
},
};
export const mutations = {
[types.SET_BULK_ACTIONS_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_SELECTED_CONVERSATION_IDS](_state, ids) {
// Check if ids is an array, if not, convert it to an array
const idsArray = Array.isArray(ids) ? ids : [ids];
// Concatenate the new IDs ensuring no duplicates
_state.selectedConversationIds = [
...new Set([..._state.selectedConversationIds, ...idsArray]),
];
},
[types.REMOVE_SELECTED_CONVERSATION_IDS](_state, id) {
_state.selectedConversationIds = _state.selectedConversationIds.filter(
item => item !== id
);
},
[types.CLEAR_SELECTED_CONVERSATION_IDS](_state) {
_state.selectedConversationIds = [];
},
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,125 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
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: [],
uiFlags: {
isFetching: false,
isCreating: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
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;
},
};
export const actions = {
get: async function getCampaigns({ commit }) {
commit(types.SET_CAMPAIGN_UI_FLAG, { isFetching: true });
try {
const response = await CampaignsAPI.get();
commit(types.SET_CAMPAIGNS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isFetching: false });
}
},
create: async function createCampaign({ commit }, campaignObj) {
commit(types.SET_CAMPAIGN_UI_FLAG, { isCreating: true });
try {
const response = await CampaignsAPI.create(campaignObj);
commit(types.ADD_CAMPAIGN, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.SET_CAMPAIGN_UI_FLAG, { isUpdating: true });
try {
const response = await CampaignsAPI.update(id, updateObj);
AnalyticsHelper.track(CAMPAIGNS_EVENTS.UPDATE_CAMPAIGN);
commit(types.EDIT_CAMPAIGN, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CAMPAIGN_UI_FLAG, { isDeleting: true });
try {
await CampaignsAPI.delete(id);
AnalyticsHelper.track(CAMPAIGNS_EVENTS.DELETE_CAMPAIGN);
commit(types.DELETE_CAMPAIGN, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CAMPAIGN_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_CAMPAIGN_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.ADD_CAMPAIGN]: MutationHelpers.create,
[types.SET_CAMPAIGNS]: MutationHelpers.set,
[types.EDIT_CAMPAIGN]: MutationHelpers.update,
[types.DELETE_CAMPAIGN]: MutationHelpers.destroy,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,115 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import CannedResponseAPI from '../../api/cannedResponse';
const state = {
records: [],
uiFlags: {
fetchingList: false,
fetchingItem: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
};
const getters = {
getCannedResponses(_state) {
return _state.records;
},
getSortedCannedResponses(_state) {
return sortOrder =>
[..._state.records].sort((a, b) => {
if (sortOrder === 'asc') {
return a.short_code.localeCompare(b.short_code);
}
return b.short_code.localeCompare(a.short_code);
});
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
const actions = {
getCannedResponse: async function getCannedResponse(
{ commit },
{ searchKey } = {}
) {
commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: true });
try {
const response = await CannedResponseAPI.get({ searchKey });
commit(types.default.SET_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: false });
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { fetchingList: false });
}
},
createCannedResponse: async function createCannedResponse(
{ commit },
cannedObj
) {
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: true });
try {
const response = await CannedResponseAPI.create(cannedObj);
commit(types.default.ADD_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { creatingItem: false });
return throwErrorMessage(error);
}
},
updateCannedResponse: async function updateCannedResponse(
{ commit },
{ id, ...updateObj }
) {
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: true });
try {
const response = await CannedResponseAPI.update(id, updateObj);
commit(types.default.EDIT_CANNED, response.data);
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
deleteCannedResponse: async function deleteCannedResponse({ commit }, id) {
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
try {
await CannedResponseAPI.delete(id);
commit(types.default.DELETE_CANNED, id);
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
return id;
} catch (error) {
commit(types.default.SET_CANNED_UI_FLAG, { deletingItem: true });
return throwErrorMessage(error);
}
},
};
const mutations = {
[types.default.SET_CANNED_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.default.SET_CANNED]: MutationHelpers.set,
[types.default.ADD_CANNED]: MutationHelpers.create,
[types.default.EDIT_CANNED]: MutationHelpers.update,
[types.default.DELETE_CANNED]: MutationHelpers.destroy,
};
export default {
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,178 @@
import * as types from '../mutation-types';
import ContactAPI from '../../api/contacts';
import ConversationApi from '../../api/conversations';
import camelcaseKeys from 'camelcase-keys';
export const createMessagePayload = (payload, message) => {
const { content, cc_emails, bcc_emails } = message;
payload.append('message[content]', content);
if (cc_emails) payload.append('message[cc_emails]', cc_emails);
if (bcc_emails) payload.append('message[bcc_emails]', bcc_emails);
};
export const createConversationPayload = ({ params, contactId, files }) => {
const { inboxId, message, sourceId, mailSubject, assigneeId } = params;
const payload = new FormData();
if (message) {
createMessagePayload(payload, message);
}
if (files && files.length > 0) {
files.forEach(file => payload.append('message[attachments][]', file));
}
payload.append('inbox_id', inboxId);
payload.append('contact_id', contactId);
payload.append('source_id', sourceId);
payload.append('additional_attributes[mail_subject]', mailSubject);
payload.append('assignee_id', assigneeId);
return payload;
};
export const createWhatsAppConversationPayload = ({ params }) => {
const { inboxId, message, contactId, sourceId, assigneeId } = params;
const payload = {
inbox_id: inboxId,
contact_id: contactId,
source_id: sourceId,
message,
assignee_id: assigneeId,
};
return payload;
};
const setNewConversationPayload = ({
isFromWhatsApp,
params,
contactId,
files,
}) => {
if (isFromWhatsApp) {
return createWhatsAppConversationPayload({ params });
}
return createConversationPayload({
params,
contactId,
files,
});
};
const state = {
records: {},
uiFlags: {
isFetching: false,
},
};
export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getContactConversation: $state => id => {
return $state.records[Number(id)] || [];
},
getAllConversationsByContactId: $state => id => {
const records = $state.records[Number(id)] || [];
return camelcaseKeys(records, { deep: true });
},
};
export const actions = {
create: async ({ commit }, { params, isFromWhatsApp }) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: true,
});
const { contactId, files } = params;
try {
const payload = setNewConversationPayload({
isFromWhatsApp,
params,
contactId,
files,
});
const { data } = await ConversationApi.create(payload);
commit(types.default.ADD_CONTACT_CONVERSATION, {
id: contactId,
data,
});
return data;
} catch (error) {
throw new Error(error);
} finally {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isCreating: false,
});
}
},
get: async ({ commit }, contactId) => {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isFetching: true,
});
try {
const response = await ContactAPI.getConversations(contactId);
commit(types.default.SET_CONTACT_CONVERSATIONS, {
id: contactId,
data: response.data.payload,
});
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isFetching: false,
});
} catch (error) {
commit(types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG, {
isFetching: false,
});
}
},
};
export const mutations = {
[types.default.SET_CONTACT_CONVERSATIONS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.default.SET_CONTACT_CONVERSATIONS]: ($state, { id, data }) => {
$state.records = {
...$state.records,
[id]: data,
};
},
[types.default.ADD_CONTACT_CONVERSATION]: ($state, { id, data }) => {
const conversations = $state.records[id] || [];
const updatedConversations = [...conversations];
const index = conversations.findIndex(
conversation => conversation.id === data.id
);
if (index !== -1) {
updatedConversations[index] = { ...conversations[index], ...data };
} else {
updatedConversations.push(data);
}
$state.records = {
...$state.records,
[id]: updatedConversations,
};
},
[types.default.DELETE_CONTACT_CONVERSATION]: ($state, id) => {
const { [id]: deletedRecord, ...remainingRecords } = $state.records;
$state.records = remainingRecords;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,90 @@
import types from '../mutation-types';
import ContactAPI from '../../api/contacts';
const state = {
records: {},
uiFlags: {
isFetching: false,
isUpdating: false,
isError: false,
},
};
export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getContactLabels: $state => id => {
return $state.records[Number(id)] || [];
},
};
export const actions = {
get: async ({ commit }, contactId) => {
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isFetching: true,
});
try {
const response = await ContactAPI.getContactLabels(contactId);
commit(types.SET_CONTACT_LABELS, {
id: contactId,
data: response.data.payload,
});
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isFetching: false,
});
} catch (error) {
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isFetching: false,
});
}
},
update: async ({ commit }, { contactId, labels }) => {
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isUpdating: true,
});
try {
const response = await ContactAPI.updateContactLabels(contactId, labels);
commit(types.SET_CONTACT_LABELS, {
id: contactId,
data: response.data.payload,
});
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isUpdating: false,
isError: false,
});
} catch (error) {
commit(types.SET_CONTACT_LABELS_UI_FLAG, {
isUpdating: false,
isError: true,
});
throw new Error(error);
}
},
setContactLabel({ commit }, { id, data }) {
commit(types.SET_CONTACT_LABELS, { id, data });
},
};
export const mutations = {
[types.SET_CONTACT_LABELS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_CONTACT_LABELS]: ($state, { id, data }) => {
$state.records = {
...$state.records,
[id]: data,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,98 @@
import types from '../mutation-types';
import ContactNotesAPI from '../../api/contactNotes';
import camelcaseKeys from 'camelcase-keys';
export const state = {
records: {},
uiFlags: {
isFetching: false,
isCreating: false,
isDeleting: false,
},
};
export const getters = {
getAllNotesByContact: _state => contactId => {
const records = _state.records[contactId] || [];
return records.sort((r1, r2) => r2.id - r1.id);
},
getUIFlags(_state) {
return _state.uiFlags;
},
getAllNotesByContactId: _state => contactId => {
const records = _state.records[contactId] || [];
const contactNotes = records.sort((r1, r2) => r2.id - r1.id);
return camelcaseKeys(contactNotes);
},
};
export const actions = {
async get({ commit }, { contactId }) {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isFetching: true });
try {
const { data } = await ContactNotesAPI.get(contactId);
commit(types.SET_CONTACT_NOTES, { contactId, data });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isFetching: false });
}
},
async create({ commit }, { contactId, content }) {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isCreating: true });
try {
const { data } = await ContactNotesAPI.create(contactId, content);
commit(types.ADD_CONTACT_NOTE, { contactId, data });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isCreating: false });
}
},
async delete({ commit }, { noteId, contactId }) {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isDeleting: true });
try {
await ContactNotesAPI.delete(contactId, noteId);
commit(types.DELETE_CONTACT_NOTE, { contactId, noteId });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CONTACT_NOTES_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_CONTACT_NOTES_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_CONTACT_NOTES]($state, { data, contactId }) {
$state.records = {
...$state.records,
[contactId]: data,
};
},
[types.ADD_CONTACT_NOTE]($state, { data, contactId }) {
const contactNotes = $state.records[contactId] || [];
$state.records[contactId] = [...contactNotes, data];
},
[types.DELETE_CONTACT_NOTE]($state, { noteId, contactId }) {
const contactNotes = $state.records[contactId];
const withoutDeletedNote = contactNotes.filter(note => note.id !== noteId);
$state.records[contactId] = [...withoutDeletedNote];
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,328 @@
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import types from '../../mutation-types';
import ContactAPI from '../../../api/contacts';
import snakecaseKeys from 'snakecase-keys';
import AccountActionsAPI from '../../../api/accountActions';
import AnalyticsHelper from '../../../helper/AnalyticsHelper';
import { CONTACTS_EVENTS } from '../../../helper/AnalyticsHelper/events';
const buildContactFormData = contactParams => {
const formData = new FormData();
const { additional_attributes = {}, ...contactProperties } = contactParams;
Object.keys(contactProperties).forEach(key => {
if (contactProperties[key]) {
formData.append(key, contactProperties[key]);
}
});
const { social_profiles, ...additionalAttributesProperties } =
additional_attributes;
Object.keys(additionalAttributesProperties).forEach(key => {
formData.append(
`additional_attributes[${key}]`,
additionalAttributesProperties[key]
);
});
Object.keys(social_profiles).forEach(key => {
formData.append(
`additional_attributes[social_profiles][${key}]`,
social_profiles[key]
);
});
return formData;
};
export const handleContactOperationErrors = error => {
if (error.response?.status === 422) {
throw new DuplicateContactException(error.response.data.attributes);
} else if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else {
throw new Error(error);
}
};
export const actions = {
search: async (
{ commit },
{ search, page, sortAttr, label, append = false }
) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.search(search, page, sortAttr, label);
if (!append) {
commit(types.CLEAR_CONTACTS);
}
commit(append ? types.APPEND_CONTACTS : 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 });
}
},
get: async ({ commit }, { page = 1, sortAttr, label } = {}) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.get(page, sortAttr, label);
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 });
}
},
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 {
const response = await ContactAPI.show(id);
commit(types.SET_CONTACT_ITEM, response.data.payload);
commit(types.SET_CONTACT_UI_FLAG, {
isFetchingItem: false,
});
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, {
isFetchingItem: false,
});
}
},
update: async ({ commit }, { id, isFormData = false, ...contactParams }) => {
const { avatar, customAttributes, ...paramsToDecamelize } = contactParams;
const decamelizedContactParams = {
...snakecaseKeys(paramsToDecamelize, { deep: true }),
...(customAttributes && { custom_attributes: customAttributes }),
...(avatar && { avatar }),
};
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
try {
const response = await ContactAPI.update(
id,
isFormData
? buildContactFormData(decamelizedContactParams)
: decamelizedContactParams
);
commit(types.EDIT_CONTACT, response.data.payload);
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
handleContactOperationErrors(error);
}
},
create: async ({ commit }, { isFormData = false, ...contactParams }) => {
const decamelizedContactParams = snakecaseKeys(contactParams, {
deep: true,
});
commit(types.SET_CONTACT_UI_FLAG, { isCreating: true });
try {
const response = await ContactAPI.create(
isFormData
? buildContactFormData(decamelizedContactParams)
: decamelizedContactParams
);
AnalyticsHelper.track(CONTACTS_EVENTS.CREATE_CONTACT);
commit(types.SET_CONTACT_ITEM, response.data.payload.contact);
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
return response.data.payload.contact;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isCreating: false });
return handleContactOperationErrors(error);
}
},
import: async ({ commit }, file) => {
commit(types.SET_CONTACT_UI_FLAG, { isImporting: true });
try {
await ContactAPI.importContacts(file);
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isImporting: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
}
}
},
export: async ({ commit }, { payload, label }) => {
commit(types.SET_CONTACT_UI_FLAG, { isExporting: true });
try {
await ContactAPI.exportContacts({ payload, label });
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isExporting: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
delete: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: true });
try {
await ContactAPI.delete(id);
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isDeleting: false });
if (error.response?.data?.message) {
throw new Error(error.response.data.message);
} else {
throw new Error(error);
}
}
},
deleteCustomAttributes: async ({ commit }, { id, customAttributes }) => {
try {
const response = await ContactAPI.destroyCustomAttributes(
id,
customAttributes
);
commit(types.EDIT_CONTACT, response.data.payload);
} catch (error) {
throw new Error(error);
}
},
deleteAvatar: async ({ commit }, id) => {
try {
const response = await ContactAPI.destroyAvatar(id);
commit(types.EDIT_CONTACT, response.data.payload);
} catch (error) {
throw new Error(error);
}
},
fetchContactableInbox: async ({ commit }, id) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: true });
try {
const response = await ContactAPI.getContactableInboxes(id);
const contact = {
id: Number(id),
contact_inboxes: response.data.payload,
};
commit(types.SET_CONTACT_ITEM, contact);
} catch (error) {
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else {
throw new Error(error);
}
} finally {
commit(types.SET_CONTACT_UI_FLAG, { isFetchingInboxes: false });
}
},
updatePresence: ({ commit }, data) => {
commit(types.UPDATE_CONTACTS_PRESENCE, data);
},
setContact({ commit }, data) {
commit(types.SET_CONTACT_ITEM, data);
},
merge: async ({ commit }, { childId, parentId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isMerging: true });
try {
const response = await AccountActionsAPI.merge(parentId, childId);
commit(types.SET_CONTACT_ITEM, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CONTACT_UI_FLAG, { isMerging: false });
}
},
deleteContactThroughConversations: ({ commit }, id) => {
commit(types.DELETE_CONTACT, id);
commit(types.CLEAR_CONTACT_CONVERSATIONS, id, { root: true });
commit(`contactConversations/${types.DELETE_CONTACT_CONVERSATION}`, id, {
root: true,
});
},
updateContact: async ({ commit }, updateObj) => {
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: true });
try {
commit(types.EDIT_CONTACT, updateObj);
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isUpdating: false });
}
},
filter: async (
{ commit },
{ page = 1, sortAttr, queryPayload, resetState = true } = {}
) => {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: true });
try {
const {
data: { payload, meta },
} = await ContactAPI.filter(page, sortAttr, queryPayload);
if (resetState) {
commit(types.CLEAR_CONTACTS);
commit(types.SET_CONTACTS, payload);
commit(types.SET_CONTACT_META, meta);
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
return payload;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isFetching: false });
}
return [];
},
setContactFilters({ commit }, data) {
commit(types.SET_CONTACT_FILTERS, data);
},
clearContactFilters({ commit }) {
commit(types.CLEAR_CONTACT_FILTERS);
},
initiateCall: async ({ commit }, { contactId, inboxId }) => {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
try {
const response = await ContactAPI.initiateCall(contactId, inboxId);
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
return response.data;
} catch (error) {
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
if (error.response?.data?.message) {
throw new ExceptionWithMessage(error.response.data.message);
} else if (error.response?.data?.error) {
throw new ExceptionWithMessage(error.response.data.error);
} else {
throw new Error(error);
}
}
},
};

View File

@@ -0,0 +1,36 @@
import camelcaseKeys from 'camelcase-keys';
export const getters = {
getContacts($state) {
return $state.sortOrder.map(contactId => $state.records[contactId]);
},
getContactsList($state) {
const contacts = $state.sortOrder.map(
contactId => $state.records[contactId]
);
return camelcaseKeys(contacts, { deep: true });
},
getUIFlags($state) {
return $state.uiFlags;
},
getContact: $state => id => {
const contact = $state.records[id];
return contact || {};
},
getContactById: $state => id => {
const contact = $state.records[id];
return camelcaseKeys(contact || {}, {
deep: true,
stopPaths: ['custom_attributes'],
});
},
getMeta: $state => {
return $state.meta;
},
getAppliedContactFilters: _state => {
return _state.appliedFilters;
},
getAppliedContactFiltersV4: _state => {
return _state.appliedFilters.map(camelcaseKeys);
},
};

View File

@@ -0,0 +1,33 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
meta: {
count: 0,
currentPage: 1,
hasMore: false,
},
records: {},
uiFlags: {
isFetching: false,
isFetchingItem: false,
isFetchingInboxes: false,
isUpdating: false,
isMerging: false,
isDeleting: false,
isExporting: false,
isImporting: false,
isInitiatingCall: false,
},
sortOrder: [],
appliedFilters: [],
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,99 @@
import types from '../../mutation-types';
import * as Sentry from '@sentry/vue';
export const mutations = {
[types.SET_CONTACT_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.CLEAR_CONTACTS]: $state => {
$state.records = {};
$state.sortOrder = [];
},
[types.SET_CONTACT_META]: ($state, data) => {
const { count, current_page: currentPage, has_more: hasMore } = data;
$state.meta.count = count;
$state.meta.currentPage = currentPage;
if (hasMore !== undefined) {
$state.meta.hasMore = hasMore;
}
},
[types.APPEND_CONTACTS]: ($state, data) => {
data.forEach(contact => {
$state.records[contact.id] = {
...($state.records[contact.id] || {}),
...contact,
};
if (!$state.sortOrder.includes(contact.id)) {
$state.sortOrder.push(contact.id);
}
});
},
[types.SET_CONTACTS]: ($state, data) => {
const sortOrder = data.map(contact => {
$state.records[contact.id] = {
...($state.records[contact.id] || {}),
...contact,
};
return contact.id;
});
$state.sortOrder = sortOrder;
},
[types.SET_CONTACT_ITEM]: ($state, data) => {
$state.records[data.id] = {
...($state.records[data.id] || {}),
...data,
};
if (!$state.sortOrder.includes(data.id)) {
$state.sortOrder.push(data.id);
}
},
[types.EDIT_CONTACT]: ($state, data) => {
$state.records[data.id] = data;
},
[types.DELETE_CONTACT]: ($state, id) => {
const index = $state.sortOrder.findIndex(item => item === id);
$state.sortOrder.splice(index, 1);
delete $state.records[id];
},
[types.UPDATE_CONTACTS_PRESENCE]: ($state, data) => {
Object.values($state.records).forEach(element => {
let availabilityStatus;
try {
availabilityStatus = data[element.id];
} catch (error) {
Sentry.setContext('contact is undefined', {
records: $state.records,
data: data,
});
Sentry.captureException(error);
return;
}
if (availabilityStatus) {
$state.records[element.id].availability_status = availabilityStatus;
} else {
$state.records[element.id].availability_status = null;
}
});
},
[types.SET_CONTACT_FILTERS](_state, data) {
_state.appliedFilters = data;
},
[types.CLEAR_CONTACT_FILTERS](_state) {
_state.appliedFilters = [];
},
};

View File

@@ -0,0 +1,100 @@
import * as types from '../mutation-types';
import ConversationAPI from '../../api/conversations';
const state = {
records: {},
uiFlags: {
isFetching: false,
isUpdating: false,
isError: false,
},
};
export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getConversationLabels: $state => id => {
return $state.records[Number(id)] || [];
},
};
export const actions = {
get: async ({ commit }, conversationId) => {
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isFetching: true,
});
try {
const response = await ConversationAPI.getLabels(conversationId);
commit(types.default.SET_CONVERSATION_LABELS, {
id: conversationId,
data: response.data.payload,
});
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isFetching: false,
});
} catch (error) {
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isFetching: false,
});
}
},
update: async ({ commit }, { conversationId, labels }) => {
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isUpdating: true,
});
try {
const response = await ConversationAPI.updateLabels(
conversationId,
labels
);
commit(types.default.SET_CONVERSATION_LABELS, {
id: conversationId,
data: response.data.payload,
});
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isUpdating: false,
isError: false,
});
} catch (error) {
commit(types.default.SET_CONVERSATION_LABELS_UI_FLAG, {
isUpdating: false,
isError: true,
});
}
},
setBulkConversationLabels({ commit }, conversations) {
commit(types.default.SET_BULK_CONVERSATION_LABELS, conversations);
},
setConversationLabel({ commit }, { id, data }) {
commit(types.default.SET_CONVERSATION_LABELS, { id, data });
},
};
export const mutations = {
[types.default.SET_CONVERSATION_LABELS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.default.SET_CONVERSATION_LABELS]: ($state, { id, data }) => {
$state.records = { ...$state.records, [id]: data };
},
[types.default.SET_BULK_CONVERSATION_LABELS]: ($state, conversations) => {
const updatedRecords = { ...$state.records };
conversations.forEach(conversation => {
updatedRecords[conversation.id] = conversation.labels;
});
$state.records = updatedRecords;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,27 @@
import * as types from '../mutation-types';
const state = {
records: {},
};
export const getters = {
getConversationMetadata: $state => id => {
return $state.records[Number(id)] || {};
},
};
export const actions = {};
export const mutations = {
[types.default.SET_CONVERSATION_METADATA]: ($state, { id, data }) => {
$state.records = { ...$state.records, [id]: data };
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,84 @@
import * as types from '../mutation-types';
const state = {
currentPage: {
me: 0,
unassigned: 0,
all: 0,
appliedFilters: 0,
},
hasEndReached: {
me: false,
unassigned: false,
all: false,
},
};
export const getters = {
getHasEndReached: $state => filter => {
return $state.hasEndReached[filter];
},
getCurrentPageFilter: $state => filter => {
return $state.currentPage[filter];
},
getCurrentPage: $state => {
return $state.currentPage;
},
};
export const actions = {
setCurrentPage({ commit }, { filter, page }) {
commit(types.default.SET_CURRENT_PAGE, { filter, page });
},
setEndReached({ commit }, { filter }) {
commit(types.default.SET_CONVERSATION_END_REACHED, { filter });
},
reset({ commit }) {
commit(types.default.CLEAR_CONVERSATION_PAGE);
},
};
export const mutations = {
[types.default.SET_CURRENT_PAGE]: ($state, { filter, page }) => {
$state.currentPage = {
...$state.currentPage,
[filter]: page,
};
},
[types.default.SET_CONVERSATION_END_REACHED]: ($state, { filter }) => {
if (filter === 'all') {
$state.hasEndReached = {
...$state.hasEndReached,
unassigned: true,
me: true,
};
}
$state.hasEndReached = {
...$state.hasEndReached,
[filter]: true,
};
},
[types.default.CLEAR_CONVERSATION_PAGE]: $state => {
$state.currentPage = {
me: 0,
unassigned: 0,
all: 0,
appliedFilters: 0,
};
$state.hasEndReached = {
me: false,
unassigned: false,
all: false,
appliedFilters: false,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,186 @@
import SearchAPI from '../../api/search';
import types from '../mutation-types';
export const initialState = {
records: [],
contactRecords: [],
conversationRecords: [],
messageRecords: [],
articleRecords: [],
uiFlags: {
isFetching: false,
isSearchCompleted: false,
contact: { isFetching: false },
conversation: { isFetching: false },
message: { isFetching: false },
article: { isFetching: false },
},
};
export const getters = {
getConversations(state) {
return state.records;
},
getContactRecords(state) {
return state.contactRecords;
},
getConversationRecords(state) {
return state.conversationRecords;
},
getMessageRecords(state) {
return state.messageRecords;
},
getArticleRecords(state) {
return state.articleRecords;
},
getUIFlags(state) {
return state.uiFlags;
},
};
export const actions = {
async get({ commit }, { q }) {
commit(types.SEARCH_CONVERSATIONS_SET, []);
if (!q) {
return;
}
commit(types.SEARCH_CONVERSATIONS_SET_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await SearchAPI.get({ q });
commit(types.SEARCH_CONVERSATIONS_SET, payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SEARCH_CONVERSATIONS_SET_UI_FLAG, {
isFetching: false,
});
}
},
async fullSearch({ commit, dispatch }, payload) {
const { q, ...filters } = payload;
if (!q && !Object.keys(filters).length) {
return;
}
commit(types.FULL_SEARCH_SET_UI_FLAG, {
isFetching: true,
isSearchCompleted: false,
});
try {
await Promise.all([
dispatch('contactSearch', { q, ...filters }),
dispatch('conversationSearch', { q, ...filters }),
dispatch('messageSearch', { q, ...filters }),
dispatch('articleSearch', { q, ...filters }),
]);
} catch (error) {
// Ignore error
} finally {
commit(types.FULL_SEARCH_SET_UI_FLAG, {
isFetching: false,
isSearchCompleted: true,
});
}
},
async contactSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.contacts({ ...searchParams, page });
commit(types.CONTACT_SEARCH_SET, data.payload.contacts);
} catch (error) {
// Ignore error
} finally {
commit(types.CONTACT_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async conversationSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.conversations({ ...searchParams, page });
commit(types.CONVERSATION_SEARCH_SET, data.payload.conversations);
} catch (error) {
// Ignore error
} finally {
commit(types.CONVERSATION_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async messageSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.messages({ ...searchParams, page });
commit(types.MESSAGE_SEARCH_SET, data.payload.messages);
} catch (error) {
// Ignore error
} finally {
commit(types.MESSAGE_SEARCH_SET_UI_FLAG, { isFetching: false });
}
},
async articleSearch({ commit }, payload) {
const { page = 1, ...searchParams } = payload;
commit(types.ARTICLE_SEARCH_SET_UI_FLAG, { isFetching: true });
try {
const { data } = await SearchAPI.articles({ ...searchParams, 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);
},
};
export const mutations = {
[types.SEARCH_CONVERSATIONS_SET](state, records) {
state.records = records;
},
[types.CONTACT_SEARCH_SET](state, records) {
state.contactRecords = [...state.contactRecords, ...records];
},
[types.CONVERSATION_SEARCH_SET](state, records) {
state.conversationRecords = [...state.conversationRecords, ...records];
},
[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 };
},
[types.FULL_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags = { ...state.uiFlags, ...uiFlags };
},
[types.CONTACT_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.contact = { ...state.uiFlags.contact, ...uiFlags };
},
[types.CONVERSATION_SEARCH_SET_UI_FLAG](state, uiFlags) {
state.uiFlags.conversation = { ...state.uiFlags.conversation, ...uiFlags };
},
[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 = [];
},
};
export default {
namespaced: true,
state: initialState,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,74 @@
import types from '../mutation-types';
import ConversationApi from '../../api/inbox/conversation';
import { debounce } from '@chatwoot/utils';
const state = {
mineCount: 0,
unAssignedCount: 0,
allCount: 0,
};
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, 1500);
const longDebouncedFetchMetaData = debounce(fetchMetaData, 5000, false, 10000);
const superLongDebouncedFetchMetaData = debounce(
fetchMetaData,
10000,
false,
20000
);
export const actions = {
get: async ({ commit, state: $state }, params) => {
if ($state.allCount > 5000) {
superLongDebouncedFetchMetaData(commit, params);
} else if ($state.allCount > 100) {
longDebouncedFetchMetaData(commit, params);
} else {
debouncedFetchMetaData(commit, params);
}
},
set({ commit }, meta) {
commit(types.SET_CONV_TAB_META, meta);
},
};
export const mutations = {
[types.SET_CONV_TAB_META](
$state,
{
mine_count: mineCount,
unassigned_count: unAssignedCount,
all_count: allCount,
} = {}
) {
$state.mineCount = mineCount;
$state.allCount = allCount;
$state.unAssignedCount = unAssignedCount;
$state.updatedOn = new Date();
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,72 @@
import * as types from '../mutation-types';
import ConversationAPI from '../../api/inbox/conversation';
const state = {
records: {},
};
export const getters = {
getUserList: $state => id => {
return $state.records[Number(id)] || [];
},
};
export const actions = {
toggleTyping: async (_, { status, conversationId, isPrivate }) => {
try {
await ConversationAPI.toggleTyping({ status, conversationId, isPrivate });
} catch (error) {
// Handle error
}
},
create: ({ commit }, { conversationId, user }) => {
commit(types.default.ADD_USER_TYPING_TO_CONVERSATION, {
conversationId,
user,
});
},
destroy: ({ commit }, { conversationId, user }) => {
commit(types.default.REMOVE_USER_TYPING_FROM_CONVERSATION, {
conversationId,
user,
});
},
};
export const mutations = {
[types.default.ADD_USER_TYPING_TO_CONVERSATION]: (
$state,
{ conversationId, user }
) => {
const records = $state.records[conversationId] || [];
const hasUserRecordAlready = !!records.filter(
record => record.id === user.id && record.type === user.type
).length;
if (!hasUserRecordAlready) {
$state.records = {
...$state.records,
[conversationId]: [...records, user],
};
}
},
[types.default.REMOVE_USER_TYPING_FROM_CONVERSATION]: (
$state,
{ conversationId, user }
) => {
const records = $state.records[conversationId] || [];
const updatedRecords = records.filter(
record => record.id !== user.id || record.type !== user.type
);
$state.records = {
...$state.records,
[conversationId]: updatedRecords,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,91 @@
import types from '../mutation-types';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import ConversationInboxApi from '../../api/inbox/conversation';
const state = {
records: {},
uiFlags: {
isFetching: false,
isUpdating: false,
},
};
export const getters = {
getUIFlags($state) {
return $state.uiFlags;
},
getByConversationId: _state => conversationId => {
return _state.records[conversationId];
},
};
export const actions = {
show: async ({ commit }, { conversationId }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isFetching: true,
});
try {
const response =
await ConversationInboxApi.fetchParticipants(conversationId);
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isFetching: false,
});
}
},
update: async ({ commit }, { conversationId, userIds }) => {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: true,
});
try {
const response = await ConversationInboxApi.updateParticipants({
conversationId,
userIds,
});
commit(types.SET_CONVERSATION_PARTICIPANTS, {
conversationId,
data: response.data,
});
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG, {
isUpdating: false,
});
}
},
};
export const mutations = {
[types.SET_CONVERSATION_PARTICIPANTS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_CONVERSATION_PARTICIPANTS]($state, { data, conversationId }) {
$state.records = {
...$state.records,
[conversationId]: data,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,539 @@
import types from '../../mutation-types';
import ConversationApi from '../../../api/inbox/conversation';
import MessageApi from '../../../api/inbox/message';
import { MESSAGE_STATUS, MESSAGE_TYPE } from 'shared/constants/messages';
import { createPendingMessage } from 'dashboard/helper/commons';
import {
buildConversationList,
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
// We have two cases
// 1. Messages that fail from the UI itself (due to large attachments or a failed network):
// In this case, the message will have a status of failed but no external error. So we need to create that message again
// 2. Messages sent from Chatwoot but failed to deliver to the customer for some reason (user blocking or client system down):
// In this case, the message will have a status of failed and an external error. So we need to retry that message
const { content_attributes: contentAttributes, status } = pendingMessage;
const externalError = contentAttributes?.external_error ?? '';
return status === MESSAGE_STATUS.FAILED && externalError !== '';
};
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
try {
const response = await ConversationApi.show(conversationId);
commit(types.UPDATE_CONVERSATION, response.data);
commit(`contacts/${types.SET_CONTACT_ITEM}`, response.data.meta.sender);
} catch (error) {
// Ignore error
}
},
fetchAllConversations: async ({ commit, state, dispatch }) => {
commit(types.SET_LIST_LOADING_STATUS);
try {
const params = state.conversationFilters;
const {
data: { data },
} = await ConversationApi.get(params);
buildConversationList(
{ commit, dispatch },
params,
data,
params.assigneeType
);
} catch (error) {
// Handle error
}
},
fetchFilteredConversations: async ({ commit, dispatch }, params) => {
commit(types.SET_LIST_LOADING_STATUS);
try {
const { data } = await ConversationApi.filter(params);
buildConversationList(
{ commit, dispatch },
params,
data,
'appliedFilters'
);
} catch (error) {
// Handle error
}
},
emptyAllConversations({ commit }) {
commit(types.EMPTY_ALL_CONVERSATION);
},
clearSelectedState({ commit }) {
commit(types.CLEAR_CURRENT_CHAT_WINDOW);
},
fetchPreviousMessages: async ({ commit }, data) => {
try {
const {
data: { meta, payload },
} = await MessageApi.getPreviousMessages(data);
commit(`conversationMetadata/${types.SET_CONVERSATION_METADATA}`, {
id: data.conversationId,
data: meta,
});
commit(types.SET_PREVIOUS_CONVERSATIONS, {
id: data.conversationId,
data: payload,
});
if (!payload.length) {
commit(types.SET_ALL_MESSAGES_LOADED, data.conversationId);
}
} catch (error) {
// Handle error
}
},
fetchAllAttachments: async ({ commit }, conversationId) => {
let attachments = [];
try {
const { data } = await ConversationApi.getAllAttachments(conversationId);
attachments = data.payload;
} catch (error) {
// in case of error, log the error and continue
Sentry.setContext('Conversation', {
id: conversationId,
});
Sentry.captureException(error);
} finally {
// we run the commit even if the request fails
// this ensures that the `attachment` variable is always present on chat
commit(types.SET_ALL_ATTACHMENTS, {
id: conversationId,
data: attachments,
});
}
},
syncActiveConversationMessages: async (
{ commit, state, dispatch },
{ conversationId }
) => {
const { allConversations, syncConversationsMessages } = state;
const lastMessageId = syncConversationsMessages[conversationId];
const selectedChat = allConversations.find(
conversation => conversation.id === conversationId
);
if (!selectedChat) return;
try {
const { messages } = selectedChat;
// Fetch all the messages after the last message id
const {
data: { meta, payload },
} = await MessageApi.getPreviousMessages({
conversationId,
after: lastMessageId,
});
commit(`conversationMetadata/${types.SET_CONVERSATION_METADATA}`, {
id: conversationId,
data: meta,
});
// Find the messages that are not already present in the store
const missingMessages = payload.filter(
message => !messages.find(item => item.id === message.id)
);
selectedChat.messages.push(...missingMessages);
// Sort the messages by created_at
const sortedMessages = selectedChat.messages.sort((a, b) => {
return new Date(a.created_at) - new Date(b.created_at);
});
commit(types.SET_MISSING_MESSAGES, {
id: conversationId,
data: sortedMessages,
});
commit(types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION, {
conversationId,
messageId: null,
});
dispatch('markMessagesRead', { id: conversationId }, { root: true });
} catch (error) {
// Handle error
}
},
setConversationLastMessageId: async (
{ commit, state },
{ conversationId }
) => {
const { allConversations } = state;
const selectedChat = allConversations.find(
conversation => conversation.id === conversationId
);
if (!selectedChat) return;
const { messages } = selectedChat;
const lastMessage = messages.last();
if (!lastMessage) return;
commit(types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION, {
conversationId,
messageId: lastMessage.id,
});
},
async setActiveChat({ commit, dispatch }, { data, after }) {
commit(types.SET_CURRENT_CHAT_WINDOW, data);
commit(types.CLEAR_ALL_MESSAGES_LOADED, data.id);
if (data.dataFetched === undefined) {
try {
await dispatch('fetchPreviousMessages', {
after,
before: data.messages[0].id,
conversationId: data.id,
});
commit(types.SET_CHAT_DATA_FETCHED, data.id);
} catch (error) {
// Ignore error
}
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
try {
const response = await ConversationApi.assignTeam({
conversationId,
teamId,
});
dispatch('setCurrentChatTeam', { team: response.data, conversationId });
} catch (error) {
// Handle error
}
},
setCurrentChatTeam({ commit }, { team, conversationId }) {
commit(types.ASSIGN_TEAM, { team, conversationId });
},
toggleStatus: async (
{ commit },
{ conversationId, status, snoozedUntil = null, customAttributes = null }
) => {
try {
// Update custom attributes first if provided
if (customAttributes) {
await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes,
});
}
const {
data: {
payload: {
current_status: updatedStatus,
snoozed_until: updatedSnoozedUntil,
} = {},
} = {},
} = await ConversationApi.toggleStatus({
conversationId,
status,
snoozedUntil,
});
commit(types.CHANGE_CONVERSATION_STATUS, {
conversationId,
status: updatedStatus,
snoozedUntil: updatedSnoozedUntil,
});
} catch (error) {
// Handle error
}
},
createPendingMessageAndSend: async ({ dispatch }, data) => {
const pendingMessage = createPendingMessage(data);
dispatch('sendMessageWithData', pendingMessage);
},
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
const response = hasMessageFailedWithExternalError(pendingMessage)
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, {
...response.data,
status: MESSAGE_STATUS.SENT,
});
} catch (error) {
const errorMessage = error.response
? error.response.data.error
: undefined;
commit(types.ADD_MESSAGE, {
...pendingMessage,
meta: {
error: errorMessage,
},
status: MESSAGE_STATUS.FAILED,
});
throw error;
}
},
addMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
if (message.message_type === MESSAGE_TYPE.INCOMING) {
commit(types.SET_CONVERSATION_CAN_REPLY, {
conversationId: message.conversation_id,
canReply: true,
});
commit(types.ADD_CONVERSATION_ATTACHMENTS, message);
}
handleVoiceCallCreated(message, rootGetters?.getCurrentUserID);
},
updateMessage({ commit, rootGetters }, message) {
commit(types.ADD_MESSAGE, message);
handleVoiceCallUpdated(commit, message, rootGetters?.getCurrentUserID);
},
deleteMessage: async function deleteLabels(
{ commit },
{ conversationId, messageId }
) {
try {
const { data } = await MessageApi.delete(conversationId, messageId);
commit(types.ADD_MESSAGE, data);
commit(types.DELETE_CONVERSATION_ATTACHMENTS, data);
} catch (error) {
throw new Error(error);
}
},
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 {
inbox_id: inboxId,
meta: { sender },
} = conversation;
const hasAppliedFilters = !!appliedFilters.length;
const isMatchingInboxFilter =
!currentInbox || Number(currentInbox) === inboxId;
if (
!hasAppliedFilters &&
!isOnFoldersView(rootState) &&
!isOnMentionsView(rootState) &&
!isOnUnattendedView(rootState) &&
isMatchingInboxFilter
) {
commit(types.ADD_CONVERSATION, conversation);
dispatch('contacts/setContact', sender);
}
},
addMentions({ dispatch, rootState }, conversation) {
if (isOnMentionsView(rootState)) {
dispatch('updateConversation', conversation);
}
},
addUnattended({ dispatch, rootState }, conversation) {
if (isOnUnattendedView(rootState)) {
dispatch('updateConversation', conversation);
}
},
updateConversation({ commit, dispatch }, conversation) {
const {
meta: { sender },
} = conversation;
commit(types.UPDATE_CONVERSATION, conversation);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
{ commit },
{ conversationId, lastActivityAt }
) {
commit(types.UPDATE_CONVERSATION_LAST_ACTIVITY, {
lastActivityAt,
conversationId,
});
},
setChatStatusFilter({ commit }, data) {
commit(types.CHANGE_CHAT_STATUS_FILTER, data);
},
setChatSortFilter({ commit }, data) {
commit(types.CHANGE_CHAT_SORT_FILTER, data);
},
updateAssignee({ commit }, data) {
commit(types.UPDATE_ASSIGNEE, data);
},
updateConversationContact({ commit }, data) {
if (data.id) {
commit(`contacts/${types.SET_CONTACT_ITEM}`, data);
}
commit(types.UPDATE_CONVERSATION_CONTACT, data);
},
setActiveInbox({ commit }, inboxId) {
commit(types.SET_ACTIVE_INBOX, inboxId);
},
muteConversation: async ({ commit }, conversationId) => {
try {
await ConversationApi.mute(conversationId);
commit(types.MUTE_CONVERSATION);
} catch (error) {
//
}
},
unmuteConversation: async ({ commit }, conversationId) => {
try {
await ConversationApi.unmute(conversationId);
commit(types.UNMUTE_CONVERSATION);
} catch (error) {
//
}
},
sendEmailTranscript: async (_, { conversationId, email }) => {
try {
await ConversationApi.sendEmailTranscript({ conversationId, email });
} catch (error) {
throw new Error(error);
}
},
updateCustomAttributes: async (
{ commit },
{ conversationId, customAttributes }
) => {
try {
const response = await ConversationApi.updateCustomAttributes({
conversationId,
customAttributes,
});
const { custom_attributes } = response.data;
commit(types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES, {
conversationId,
customAttributes: custom_attributes,
});
} catch (error) {
// Handle error
}
},
setConversationFilters({ commit }, data) {
commit(types.SET_CONVERSATION_FILTERS, data);
},
clearConversationFilters({ commit }) {
commit(types.CLEAR_CONVERSATION_FILTERS);
},
setChatListFilters({ commit }, data) {
commit(types.SET_CHAT_LIST_FILTERS, data);
},
updateChatListFilters({ commit }, data) {
commit(types.UPDATE_CHAT_LIST_FILTERS, data);
},
assignPriority: async ({ dispatch }, { conversationId, priority }) => {
try {
await ConversationApi.togglePriority({
conversationId,
priority,
});
dispatch('setCurrentChatPriority', {
priority,
conversationId,
});
} catch (error) {
// Handle error
}
},
setCurrentChatPriority({ commit }, { priority, conversationId }) {
commit(types.ASSIGN_PRIORITY, { priority, conversationId });
},
setContextMenuChatId({ commit }, chatId) {
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,
};
export default actions;

View File

@@ -0,0 +1,35 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import ConversationApi from '../../../../api/inbox/conversation';
import mutationTypes from '../../../mutation-types';
export default {
markMessagesRead: async ({ commit }, data) => {
try {
const {
data: { id, agent_last_seen_at: lastSeen },
} = await ConversationApi.markMessageRead(data);
setTimeout(
() =>
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, { id, lastSeen }),
4000
);
} catch (error) {
// Handle error
}
},
markMessagesUnread: async ({ commit }, { id }) => {
try {
const {
data: { agent_last_seen_at: lastSeen, unread_count: unreadCount },
} = await ConversationApi.markMessagesUnread({ id });
commit(mutationTypes.UPDATE_MESSAGE_UNREAD_COUNT, {
id,
lastSeen,
unreadCount,
});
} catch (error) {
throwErrorMessage(error);
}
},
};

View File

@@ -0,0 +1,15 @@
import MessageApi from '../../../../api/inbox/message';
export default {
async translateMessage(_, { conversationId, messageId, targetLanguage }) {
try {
await MessageApi.translateMessage(
conversationId,
messageId,
targetLanguage
);
} catch (error) {
// ignore error
}
},
};

View File

@@ -0,0 +1,166 @@
import { MESSAGE_TYPE } from 'shared/constants/messages';
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 = ({
allConversations,
selectedChatId,
}) =>
allConversations.filter(conversation => conversation.id === selectedChatId);
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
);
return selectedChat || {};
},
getSelectedChatAttachments: ({ selectedChatId, attachments }) => {
return attachments[selectedChatId] || [];
},
getChatListFilters: ({ conversationFilters }) => conversationFilters,
getLastEmailInSelectedChat: (stage, _getters) => {
const selectedChat = _getters.getSelectedChat;
const { messages = [] } = selectedChat;
const lastEmail = [...messages].reverse().find(message => {
const { message_type: messageType } = message;
if (message.private) return false;
return [MESSAGE_TYPE.OUTGOING, MESSAGE_TYPE.INCOMING].includes(
messageType
);
});
return lastEmail;
},
getMineChats: (_state, _, __, rootGetters) => activeFilters => {
const currentUserID = rootGetters.getCurrentUser?.id;
return _state.allConversations.filter(conversation => {
const { assignee } = conversation.meta;
const isAssignedToMe = assignee && assignee.id === currentUserID;
const shouldFilter = applyPageFilters(conversation, activeFilters);
const isChatMine = isAssignedToMe && shouldFilter;
return isChatMine;
});
},
getAppliedConversationFiltersV2: _state => {
// TODO: Replace existing one with V2 after migrating the filters to use camelcase
return _state.appliedFilters.map(camelcaseKeys);
},
getAppliedConversationFilters: _state => {
return _state.appliedFilters;
},
getAppliedConversationFiltersQuery: _state => {
const hasAppliedFilters = _state.appliedFilters.length !== 0;
return hasAppliedFilters ? filterQueryGenerator(_state.appliedFilters) : [];
},
getUnAssignedChats: _state => activeFilters => {
return _state.allConversations.filter(conversation => {
const isUnAssigned = !conversation.meta.assignee;
const shouldFilter = applyPageFilters(conversation, activeFilters);
return isUnAssigned && shouldFilter;
});
},
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);
const allowedForRole = applyRoleFilter(
conversation,
userRole,
permissions,
currentUserId
);
return shouldFilter && allowedForRole;
});
},
getChatListLoadingStatus: ({ listLoadingStatus }) => listLoadingStatus,
getAllMessagesLoaded(_state) {
const [chat] = getSelectedChatConversation(_state);
return !chat || chat.allMessagesLoaded === undefined
? false
: chat.allMessagesLoaded;
},
getUnreadCount(_state) {
const [chat] = getSelectedChatConversation(_state);
if (!chat) return [];
return chat.messages.filter(
chatMessage =>
chatMessage.created_at * 1000 > chat.agent_last_seen_at * 1000 &&
chatMessage.message_type === 0 &&
chatMessage.private !== true
).length;
},
getChatStatusFilter: ({ chatStatusFilter }) => chatStatusFilter,
getChatSortFilter: ({ chatSortFilter }) => chatSortFilter,
getSelectedInbox: ({ currentInbox }) => currentInbox,
getConversationById: _state => conversationId => {
return _state.allConversations.find(
value => value.id === Number(conversationId)
);
},
getConversationParticipants: _state => {
return _state.conversationParticipants;
},
getConversationLastSeen: _state => {
return _state.conversationLastSeen;
},
getContextMenuChatId: _state => {
return _state.contextMenuChatId;
},
getCopilotAssistant: _state => {
return _state.copilotAssistant;
},
};
export default getters;

View File

@@ -0,0 +1,159 @@
import { CONVERSATION_PRIORITY_ORDER } from 'shared/constants/messages';
export const findPendingMessageIndex = (chat, message) => {
const { echo_id: tempMessageId } = message;
return chat.messages.findIndex(
m => m.id === message.id || m.id === tempMessageId
);
};
export const filterByStatus = (chatStatus, filterStatus) =>
filterStatus === 'all' ? true : chatStatus === filterStatus;
export const filterByInbox = (shouldFilter, inboxId, chatInboxId) => {
const isOnInbox = Number(inboxId) === chatInboxId;
return inboxId ? isOnInbox && shouldFilter : shouldFilter;
};
export const filterByTeam = (shouldFilter, teamId, chatTeamId) => {
const isOnTeam = Number(teamId) === chatTeamId;
return teamId ? isOnTeam && shouldFilter : shouldFilter;
};
export const filterByLabel = (shouldFilter, labels, chatLabels) => {
const isOnLabel = labels.every(label => chatLabels.includes(label));
return labels.length ? isOnLabel && shouldFilter : shouldFilter;
};
export const filterByUnattended = (
shouldFilter,
conversationType,
firstReplyOn,
waitingSince
) => {
return conversationType === 'unattended'
? (!firstReplyOn || !!waitingSince) && shouldFilter
: shouldFilter;
};
export const applyPageFilters = (conversation, filters) => {
const { inboxId, status, labels = [], teamId, conversationType } = filters;
const {
status: chatStatus,
inbox_id: chatInboxId,
labels: chatLabels = [],
meta = {},
first_reply_created_at: firstReplyOn,
waiting_since: waitingSince,
} = conversation;
const team = meta.team || {};
const { id: chatTeamId } = team;
let shouldFilter = filterByStatus(chatStatus, status);
shouldFilter = filterByInbox(shouldFilter, inboxId, chatInboxId);
shouldFilter = filterByTeam(shouldFilter, teamId, chatTeamId);
shouldFilter = filterByLabel(shouldFilter, labels, chatLabels);
shouldFilter = filterByUnattended(
shouldFilter,
conversationType,
firstReplyOn,
waitingSince
);
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'],
created_at_asc: ['sortOnCreatedAt', 'asc'],
created_at_desc: ['sortOnCreatedAt', 'desc'],
priority_asc: ['sortOnPriority', 'asc'],
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
const getSortOrderFunction = sortOrder =>
sortOrder === 'asc' ? sortAscending : sortDescending;
const sortConfig = {
sortOnLastActivityAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.last_activity_at, b.last_activity_at),
sortOnCreatedAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.created_at, b.created_at),
sortOnPriority: (a, b, sortDirection) => {
const DEFAULT_FOR_NULL = sortDirection === 'asc' ? 5 : 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
if (!a.waiting_since && !b.waiting_since) {
return sortFunc(a.created_at, b.created_at);
}
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
}
return sortFunc(a.waiting_since, b.waiting_since);
},
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] =
SORT_OPTIONS[sortKey] || SORT_OPTIONS.last_activity_at_desc;
return sortConfig[sortMethod](a, b, sortDirection);
};

View File

@@ -0,0 +1,62 @@
import types from '../../../mutation-types';
export const setPageFilter = ({ dispatch, filter, page, markEndReached }) => {
dispatch('conversationPage/setCurrentPage', { filter, page }, { root: true });
if (markEndReached) {
dispatch('conversationPage/setEndReached', { filter }, { root: true });
}
};
export const setContacts = (commit, chatList) => {
commit(
`contacts/${types.SET_CONTACTS}`,
chatList.map(chat => chat.meta.sender)
);
};
export const isOnMentionsView = ({ route: { name: routeName } }) => {
const MENTION_ROUTES = [
'conversation_mentions',
'conversation_through_mentions',
];
return MENTION_ROUTES.includes(routeName);
};
export const isOnUnattendedView = ({ route: { name: routeName } }) => {
const UNATTENDED_ROUTES = [
'conversation_unattended',
'conversation_through_unattended',
];
return UNATTENDED_ROUTES.includes(routeName);
};
export const isOnFoldersView = ({ route: { name: routeName } }) => {
const FOLDER_ROUTES = [
'folder_conversations',
'conversations_through_folders',
];
return FOLDER_ROUTES.includes(routeName);
};
export const buildConversationList = (
context,
requestPayload,
responseData,
filterType
) => {
const { payload: conversationList, meta: metaData } = responseData;
context.commit(types.SET_ALL_CONVERSATION, conversationList);
context.dispatch('conversationStats/set', metaData);
context.dispatch(
'conversationLabels/setBulkConversationLabels',
conversationList
);
context.commit(types.CLEAR_LIST_LOADING_STATUS);
setContacts(context.commit, conversationList);
setPageFilter({
dispatch: context.dispatch,
filter: filterType,
page: requestPayload.page,
markEndReached: !conversationList.length,
});
};

View File

@@ -0,0 +1,385 @@
/**
* 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 'labels':
case 'created_at':
case 'last_activity_at':
return conversation[attributeKey];
case 'display_id':
// Frontend uses 'id' but backend expects 'display_id'
return conversation.display_id || conversation.id;
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 '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' &&
typeof filterValue === '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;
const isNullish =
conversationValue === null || conversationValue === undefined;
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 !isNullish;
case 'is_not_present':
return isNullish;
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': {
if (isNullish) {
return false;
}
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));
};

View File

@@ -0,0 +1,26 @@
import { isOnMentionsView, isOnFoldersView } from '../actionHelpers';
describe('#isOnMentionsView', () => {
it('return valid responses when passing the state', () => {
expect(isOnMentionsView({ route: { name: 'conversation_mentions' } })).toBe(
true
);
expect(isOnMentionsView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});
describe('#isOnFoldersView', () => {
it('return valid responses when passing the state', () => {
expect(isOnFoldersView({ route: { name: 'folder_conversations' } })).toBe(
true
);
expect(
isOnFoldersView({ route: { name: 'conversations_through_folders' } })
).toBe(true);
expect(isOnFoldersView({ route: { name: 'conversation_messages' } })).toBe(
false
);
});
});

View File

@@ -0,0 +1,386 @@
import types from '../../mutation-types';
import getters, { getSelectedChatConversation } from './getters';
import actions from './actions';
import { findPendingMessageIndex } from './helpers';
import { MESSAGE_STATUS } from 'shared/constants/messages';
import wootConstants from 'dashboard/constants/globals';
import { BUS_EVENTS } from '../../../../shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { CONTENT_TYPES } from 'dashboard/components-next/message/constants.js';
const state = {
allConversations: [],
attachments: {},
listLoadingStatus: true,
chatStatusFilter: wootConstants.STATUS_TYPE.OPEN,
chatSortFilter: wootConstants.SORT_BY_TYPE.LATEST,
currentInbox: null,
selectedChatId: null,
appliedFilters: [],
contextMenuChatId: null,
conversationParticipants: [],
conversationLastSeen: null,
syncConversationsMessages: {},
conversationFilters: {},
copilotAssistant: {},
};
const getConversationById = _state => conversationId => {
return _state.allConversations.find(c => c.id === conversationId);
};
// mutations
export const mutations = {
[types.SET_ALL_CONVERSATION](_state, conversationList) {
const newAllConversations = [..._state.allConversations];
conversationList.forEach(conversation => {
const indexInCurrentList = newAllConversations.findIndex(
c => c.id === conversation.id
);
if (indexInCurrentList < 0) {
newAllConversations.push(conversation);
} else if (conversation.id !== _state.selectedChatId) {
// If the conversation is already in the list, replace it
// Added this to fix the issue of the conversation not being updated
// When reconnecting to the websocket. If the selectedChatId is not the same as
// the conversation.id in the store, replace the existing conversation with the new one
newAllConversations[indexInCurrentList] = conversation;
} else {
// If the conversation is already in the list and selectedChatId is the same,
// replace all data except the messages array, attachments, dataFetched, allMessagesLoaded
const existingConversation = newAllConversations[indexInCurrentList];
newAllConversations[indexInCurrentList] = {
...conversation,
allMessagesLoaded: existingConversation.allMessagesLoaded,
messages: existingConversation.messages,
dataFetched: existingConversation.dataFetched,
};
}
});
_state.allConversations = newAllConversations;
},
[types.EMPTY_ALL_CONVERSATION](_state) {
_state.allConversations = [];
_state.selectedChatId = null;
},
[types.SET_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = true;
}
},
[types.CLEAR_ALL_MESSAGES_LOADED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.allMessagesLoaded = false;
}
},
[types.CLEAR_CURRENT_CHAT_WINDOW](_state) {
_state.selectedChatId = null;
},
[types.SET_PREVIOUS_CONVERSATIONS](_state, { id, data }) {
if (data.length) {
const [chat] = _state.allConversations.filter(c => c.id === id);
chat.messages.unshift(...data);
}
},
[types.SET_ALL_ATTACHMENTS](_state, { id, data }) {
_state.attachments[id] = [...data];
},
[types.SET_MISSING_MESSAGES](_state, { id, data }) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (!chat) return;
chat.messages = data;
},
[types.SET_CHAT_DATA_FETCHED](_state, conversationId) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.dataFetched = true;
}
},
[types.SET_CURRENT_CHAT_WINDOW](_state, activeChat) {
if (activeChat) {
_state.selectedChatId = activeChat.id;
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
}
},
[types.ASSIGN_TEAM](_state, { team, conversationId }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.meta.team = team;
},
[types.UPDATE_CONVERSATION_LAST_ACTIVITY](
_state,
{ lastActivityAt, conversationId }
) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.last_activity_at = lastActivityAt;
}
},
[types.ASSIGN_PRIORITY](_state, { priority, conversationId }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
chat.priority = priority;
},
[types.UPDATE_CONVERSATION_CUSTOM_ATTRIBUTES](
_state,
{ conversationId, customAttributes }
) {
const conversation = _state.allConversations.find(
c => c.id === conversationId
);
if (conversation) {
conversation.custom_attributes = {
...conversation.custom_attributes,
...customAttributes,
};
}
},
[types.CHANGE_CONVERSATION_STATUS](
_state,
{ conversationId, status, snoozedUntil }
) {
const conversation =
getters.getConversationById(_state)(conversationId) || {};
conversation.snoozed_until = snoozedUntil;
conversation.status = status;
},
[types.MUTE_CONVERSATION](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.muted = true;
},
[types.UNMUTE_CONVERSATION](_state) {
const [chat] = getSelectedChatConversation(_state);
chat.muted = false;
},
[types.ADD_CONVERSATION_ATTACHMENTS](_state, message) {
// early return if the message has not been sent, or has no attachments
if (
message.status !== MESSAGE_STATUS.SENT ||
!message.attachments?.length
) {
return;
}
const id = message.conversation_id;
const existingAttachments = _state.attachments[id] || [];
const attachmentsToAdd = message.attachments.filter(attachment => {
// if the attachment is not already in the store, add it
// this is to prevent duplicates
return !existingAttachments.some(
existingAttachment => existingAttachment.id === attachment.id
);
});
// replace the attachments in the store
_state.attachments[id] = [...existingAttachments, ...attachmentsToAdd];
},
[types.DELETE_CONVERSATION_ATTACHMENTS](_state, message) {
if (message.status !== MESSAGE_STATUS.SENT) return;
const { conversation_id: id } = message;
const existingAttachments = _state.attachments[id] || [];
if (!existingAttachments.length) return;
_state.attachments[id] = existingAttachments.filter(attachment => {
return attachment.message_id !== message.id;
});
},
[types.ADD_MESSAGE]({ allConversations, selectedChatId }, message) {
const { conversation_id: conversationId } = message;
const [chat] = getSelectedChatConversation({
allConversations,
selectedChatId: conversationId,
});
if (!chat) return;
const pendingMessageIndex = findPendingMessageIndex(chat, message);
if (pendingMessageIndex !== -1) {
chat.messages[pendingMessageIndex] = message;
} else {
chat.messages.push(message);
chat.timestamp = message.created_at;
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
chat.unread_count = unreadCount;
if (selectedChatId === conversationId) {
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
}
}
},
[types.ADD_CONVERSATION](_state, conversation) {
_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 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.SCROLL_TO_MESSAGE);
}
} else {
_state.allConversations.push(conversation);
}
},
[types.SET_LIST_LOADING_STATUS](_state) {
_state.listLoadingStatus = true;
},
[types.CLEAR_LIST_LOADING_STATUS](_state) {
_state.listLoadingStatus = false;
},
[types.UPDATE_MESSAGE_UNREAD_COUNT](
_state,
{ id, lastSeen, unreadCount = 0 }
) {
const [chat] = _state.allConversations.filter(c => c.id === id);
if (chat) {
chat.agent_last_seen_at = lastSeen;
chat.unread_count = unreadCount;
}
},
[types.CHANGE_CHAT_STATUS_FILTER](_state, data) {
_state.chatStatusFilter = data;
},
[types.CHANGE_CHAT_SORT_FILTER](_state, data) {
_state.chatSortFilter = data;
},
// Update assignee on action cable message
[types.UPDATE_ASSIGNEE](_state, payload) {
const chat = getConversationById(_state)(payload.id);
if (chat) {
chat.meta.assignee = payload.assignee;
}
},
[types.UPDATE_CONVERSATION_CONTACT](_state, { conversationId, ...payload }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.meta.sender = payload;
}
},
[types.UPDATE_CONVERSATION_CALL_STATUS](
_state,
{ conversationId, callStatus }
) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
chat.additional_attributes = {
...chat.additional_attributes,
call_status: callStatus,
};
},
[types.UPDATE_MESSAGE_CALL_STATUS](_state, { conversationId, callStatus }) {
const chat = getConversationById(_state)(conversationId);
if (!chat) return;
const lastCall = (chat.messages || []).findLast(
m => m.content_type === CONTENT_TYPES.VOICE_CALL
);
if (!lastCall) return;
lastCall.content_attributes ??= {};
lastCall.content_attributes.data = {
...lastCall.content_attributes.data,
status: callStatus,
};
},
[types.SET_ACTIVE_INBOX](_state, inboxId) {
_state.currentInbox = inboxId ? parseInt(inboxId, 10) : null;
},
[types.SET_CONVERSATION_CAN_REPLY](_state, { conversationId, canReply }) {
const [chat] = _state.allConversations.filter(c => c.id === conversationId);
if (chat) {
chat.can_reply = canReply;
}
},
[types.CLEAR_CONTACT_CONVERSATIONS](_state, contactId) {
const chats = _state.allConversations.filter(
c => c.meta.sender.id !== contactId
);
_state.allConversations = chats;
},
[types.SET_CONVERSATION_FILTERS](_state, data) {
_state.appliedFilters = data;
},
[types.CLEAR_CONVERSATION_FILTERS](_state) {
_state.appliedFilters = [];
},
[types.SET_LAST_MESSAGE_ID_IN_SYNC_CONVERSATION](
_state,
{ conversationId, messageId }
) {
_state.syncConversationsMessages[conversationId] = messageId;
},
[types.SET_CONTEXT_MENU_CHAT_ID](_state, chatId) {
_state.contextMenuChatId = chatId;
},
[types.SET_CHAT_LIST_FILTERS](_state, data) {
_state.conversationFilters = data;
},
[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 {
state,
getters,
actions,
mutations,
};

View File

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

View File

@@ -0,0 +1,159 @@
import { mutations } from '../index';
import types from '../../../mutation-types';
describe('#mutations', () => {
describe('#UPDATE_CONVERSATION_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('updates call_status preserving existing additional_attributes', () => {
const state = {
allConversations: [
{ id: 1, additional_attributes: { other_attr: 'value' } },
],
};
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(state.allConversations[0].additional_attributes).toEqual({
other_attr: 'value',
call_status: 'in-progress',
});
});
it('creates additional_attributes if it does not exist', () => {
const state = { allConversations: [{ id: 1 }] };
mutations[types.UPDATE_CONVERSATION_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(state.allConversations[0].additional_attributes).toEqual({
call_status: 'completed',
});
});
});
describe('#UPDATE_MESSAGE_CALL_STATUS', () => {
it('does nothing if conversation is not found', () => {
const state = { allConversations: [] };
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations).toEqual([]);
});
it('does nothing if no voice call message exists', () => {
const state = {
allConversations: [
{ id: 1, messages: [{ id: 1, content_type: 'text' }] },
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages[0]).toEqual({
id: 1,
content_type: 'text',
});
});
it('updates the last voice call message status', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
{
id: 2,
content_type: 'voice_call',
content_attributes: { data: { status: 'ringing' } },
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('ringing');
expect(
state.allConversations[0].messages[1].content_attributes.data.status
).toBe('in-progress');
});
it('creates content_attributes.data if it does not exist', () => {
const state = {
allConversations: [
{
id: 1,
messages: [{ id: 1, content_type: 'voice_call' }],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'completed',
});
expect(
state.allConversations[0].messages[0].content_attributes.data.status
).toBe('completed');
});
it('preserves existing data in content_attributes.data', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
id: 1,
content_type: 'voice_call',
content_attributes: {
data: { call_sid: 'CA123', status: 'ringing' },
},
},
],
},
],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'in-progress',
});
expect(
state.allConversations[0].messages[0].content_attributes.data
).toEqual({
call_sid: 'CA123',
status: 'in-progress',
});
});
it('handles empty messages array', () => {
const state = {
allConversations: [{ id: 1, messages: [] }],
};
mutations[types.UPDATE_MESSAGE_CALL_STATUS](state, {
conversationId: 1,
callStatus: 'ringing',
});
expect(state.allConversations[0].messages).toEqual([]);
});
});
});

View File

@@ -0,0 +1,166 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CSATReports from '../../api/csatReports';
import { downloadCsvFile } from '../../helper/downloadHelper';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
const computeDistribution = (value, total) =>
((value * 100) / total).toFixed(2);
export const state = {
records: [],
metrics: {
totalResponseCount: 0,
ratingsCount: {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
},
totalSentMessagesCount: 0,
},
uiFlags: {
isFetching: false,
isFetchingMetrics: false,
},
};
export const getters = {
getCSATResponses(_state) {
return _state.records;
},
getMetrics(_state) {
return _state.metrics;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getSatisfactionScore(_state) {
if (!_state.metrics.totalResponseCount) {
return 0;
}
return computeDistribution(
_state.metrics.ratingsCount[4] + _state.metrics.ratingsCount[5],
_state.metrics.totalResponseCount
);
},
getResponseRate(_state) {
if (!_state.metrics.totalSentMessagesCount) {
return 0;
}
return computeDistribution(
_state.metrics.totalResponseCount,
_state.metrics.totalSentMessagesCount
);
},
getRatingPercentage(_state) {
if (!_state.metrics.totalResponseCount) {
return { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
}
return {
1: computeDistribution(
_state.metrics.ratingsCount[1],
_state.metrics.totalResponseCount
),
2: computeDistribution(
_state.metrics.ratingsCount[2],
_state.metrics.totalResponseCount
),
3: computeDistribution(
_state.metrics.ratingsCount[3],
_state.metrics.totalResponseCount
),
4: computeDistribution(
_state.metrics.ratingsCount[4],
_state.metrics.totalResponseCount
),
5: computeDistribution(
_state.metrics.ratingsCount[5],
_state.metrics.totalResponseCount
),
};
},
getRatingCount(_state) {
return _state.metrics.ratingsCount;
},
};
export const actions = {
get: async function getResponses({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: true });
try {
const response = await CSATReports.get(params);
commit(types.SET_CSAT_RESPONSE, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetching: false });
}
},
getMetrics: async function getMetrics({ commit }, params) {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: true });
try {
const response = await CSATReports.getMetrics(params);
commit(types.SET_CSAT_RESPONSE_METRICS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CSAT_RESPONSE_UI_FLAG, { isFetchingMetrics: false });
}
},
downloadCSATReports(_, params) {
return CSATReports.download(params).then(response => {
downloadCsvFile(params.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'csat',
});
});
},
update: async ({ commit }, { id, reviewNotes }) => {
const response = await CSATReports.update(id, {
csat_review_notes: reviewNotes,
});
commit(types.UPDATE_CSAT_RESPONSE, response.data);
return response.data;
},
};
export const mutations = {
[types.SET_CSAT_RESPONSE_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_CSAT_RESPONSE]: MutationHelpers.set,
[types.SET_CSAT_RESPONSE_METRICS](
_state,
{
total_count: totalResponseCount,
ratings_count: ratingsCount,
total_sent_messages_count: totalSentMessagesCount,
}
) {
_state.metrics.totalResponseCount = totalResponseCount || 0;
_state.metrics.ratingsCount = {
1: ratingsCount['1'] || 0,
2: ratingsCount['2'] || 0,
3: ratingsCount['3'] || 0,
4: ratingsCount['4'] || 0,
5: ratingsCount['5'] || 0,
};
_state.metrics.totalSentMessagesCount = totalSentMessagesCount || 0;
},
[types.UPDATE_CSAT_RESPONSE]: MutationHelpers.update,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,100 @@
import { throwErrorMessage } from 'dashboard/store/utils/api';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import CustomRoleAPI from '../../api/customRole';
export const state = {
records: [],
uiFlags: {
fetchingList: false,
creatingItem: false,
updatingItem: false,
deletingItem: false,
},
};
export const getters = {
getCustomRoles($state) {
return $state.records;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
getCustomRole: async function getCustomRole({ commit }) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: true });
try {
const response = await CustomRoleAPI.get();
commit(types.default.SET_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false });
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { fetchingList: false });
}
},
createCustomRole: async function createCustomRole({ commit }, customRoleObj) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: true });
try {
const response = await CustomRoleAPI.create(customRoleObj);
commit(types.default.ADD_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { creatingItem: false });
return throwErrorMessage(error);
}
},
updateCustomRole: async function updateCustomRole(
{ commit },
{ id, ...updateObj }
) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: true });
try {
const response = await CustomRoleAPI.update(id, updateObj);
commit(types.default.EDIT_CUSTOM_ROLE, response.data);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
deleteCustomRole: async function deleteCustomRole({ commit }, id) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
try {
await CustomRoleAPI.delete(id);
commit(types.default.DELETE_CUSTOM_ROLE, id);
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
return id;
} catch (error) {
commit(types.default.SET_CUSTOM_ROLE_UI_FLAG, { deletingItem: true });
return throwErrorMessage(error);
}
},
};
export const mutations = {
[types.default.SET_CUSTOM_ROLE_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.default.SET_CUSTOM_ROLE]: MutationHelpers.set,
[types.default.ADD_CUSTOM_ROLE]: MutationHelpers.create,
[types.default.EDIT_CUSTOM_ROLE]: MutationHelpers.update,
[types.default.DELETE_CUSTOM_ROLE]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,144 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import CustomViewsAPI from '../../api/customViews';
const VIEW_TYPES = {
CONVERSATION: 'conversation',
CONTACT: 'contact',
};
// use to normalize the filter type
const FILTER_KEYS = {
0: VIEW_TYPES.CONVERSATION,
1: VIEW_TYPES.CONTACT,
[VIEW_TYPES.CONVERSATION]: VIEW_TYPES.CONVERSATION,
[VIEW_TYPES.CONTACT]: VIEW_TYPES.CONTACT,
};
export const state = {
[VIEW_TYPES.CONVERSATION]: {
records: [],
},
[VIEW_TYPES.CONTACT]: {
records: [],
},
uiFlags: {
isFetching: false,
isCreating: false,
isDeleting: false,
},
activeConversationFolder: null,
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getCustomViewsByFilterType: _state => key => {
const filterType = FILTER_KEYS[key];
return _state[filterType].records;
},
getConversationCustomViews(_state) {
return _state[VIEW_TYPES.CONVERSATION].records;
},
getContactCustomViews(_state) {
return _state[VIEW_TYPES.CONTACT].records;
},
getActiveConversationFolder(_state) {
return _state.activeConversationFolder;
},
};
export const actions = {
get: async function getCustomViews({ commit }, filterType) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: true });
try {
const response =
await CustomViewsAPI.getCustomViewsByFilterType(filterType);
commit(types.SET_CUSTOM_VIEW, { data: response.data, filterType });
} catch (error) {
// Ignore error
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isFetching: false });
}
},
create: async function createCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.create(obj);
commit(types.ADD_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
});
return response;
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
update: async function updateCustomViews({ commit }, obj) {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: true });
try {
const response = await CustomViewsAPI.update(obj.id, obj);
commit(types.UPDATE_CUSTOM_VIEW, {
data: response.data,
filterType: FILTER_KEYS[obj.filter_type],
});
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isCreating: false });
}
},
delete: async ({ commit }, { id, filterType }) => {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: true });
try {
await CustomViewsAPI.deleteCustomViews(id, filterType);
commit(types.DELETE_CUSTOM_VIEW, { data: id, filterType });
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_CUSTOM_VIEW_UI_FLAG, { isDeleting: false });
}
},
setActiveConversationFolder({ commit }, data) {
commit(types.SET_ACTIVE_CONVERSATION_FOLDER, data);
},
};
export const mutations = {
[types.SET_CUSTOM_VIEW_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.ADD_CUSTOM_VIEW]: (_state, { data, filterType }) => {
MutationHelpers.create(_state[filterType], data);
},
[types.SET_CUSTOM_VIEW]: (_state, { data, filterType }) => {
MutationHelpers.set(_state[filterType], data);
},
[types.UPDATE_CUSTOM_VIEW]: (_state, { data, filterType }) => {
MutationHelpers.update(_state[filterType], data);
},
[types.DELETE_CUSTOM_VIEW]: (_state, { data, filterType }) => {
MutationHelpers.destroy(_state[filterType], data);
},
[types.SET_ACTIVE_CONVERSATION_FOLDER](_state, folder) {
_state.activeConversationFolder = folder;
},
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,91 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import DashboardAppsAPI from '../../api/dashboardApps';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isCreating: false,
isDeleting: false,
},
};
export const getters = {
getUIFlags(_state) {
return _state.uiFlags;
},
getRecords(_state) {
return _state.records;
},
};
export const actions = {
get: async function getDashboardApps({ commit }) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isFetching: true });
try {
const response = await DashboardAppsAPI.get();
commit(types.SET_DASHBOARD_APPS, response.data);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isFetching: false });
}
},
create: async function createApp({ commit }, appObj) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: true });
try {
const response = await DashboardAppsAPI.create(appObj);
commit(types.CREATE_DASHBOARD_APP, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isCreating: false });
}
},
update: async function updateApp({ commit }, { id, ...updateObj }) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: true });
try {
const response = await DashboardAppsAPI.update(id, updateObj);
commit(types.EDIT_DASHBOARD_APP, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isUpdating: false });
}
},
delete: async function deleteApp({ commit }, id) {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: true });
try {
await DashboardAppsAPI.delete(id);
commit(types.DELETE_DASHBOARD_APP, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_DASHBOARD_APPS_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_DASHBOARD_APPS_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_DASHBOARD_APPS]: MutationHelpers.set,
[types.CREATE_DASHBOARD_APP]: MutationHelpers.create,
[types.EDIT_DASHBOARD_APP]: MutationHelpers.update,
[types.DELETE_DASHBOARD_APP]: MutationHelpers.destroy,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,55 @@
import types from '../mutation-types';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
const state = {
records: LocalStorage.get(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES) || {},
replyEditorMode: REPLY_EDITOR_MODES.REPLY,
};
export const getters = {
get: _state => key => {
return _state.records[key] || '';
},
getReplyEditorMode: _state => _state.replyEditorMode,
};
export const actions = {
set: async ({ commit }, { key, message }) => {
commit(types.SET_DRAFT_MESSAGES, { key, message });
},
delete: ({ commit }, { key }) => {
commit(types.SET_DRAFT_MESSAGES, { key });
},
setReplyEditorMode: ({ commit }, { mode }) => {
commit(types.SET_REPLY_EDITOR_MODE, { mode });
},
};
export const mutations = {
[types.SET_DRAFT_MESSAGES]($state, { key, message }) {
$state.records = {
...$state.records,
[key]: message,
};
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.records);
},
[types.REMOVE_DRAFT_MESSAGES]($state, { key }) {
const { [key]: draftToBeRemoved, ...updatedRecords } = $state.records;
$state.records = updatedRecords;
LocalStorage.set(LOCAL_STORAGE_KEYS.DRAFT_MESSAGES, $state.records);
},
[types.SET_REPLY_EDITOR_MODE]($state, { mode }) {
$state.replyEditorMode = mode;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,183 @@
import articlesAPI from 'dashboard/api/helpCenter/articles';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import camelcaseKeys from 'camelcase-keys';
import types from '../../mutation-types';
export const actions = {
index: async (
{ commit },
{ pageNumber, portalSlug, locale, status, authorId, categorySlug }
) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
const { data } = await articlesAPI.getArticles({
pageNumber,
portalSlug,
locale,
status,
authorId,
categorySlug,
});
const payload = camelcaseKeys(data.payload);
const meta = camelcaseKeys(data.meta);
const articleIds = payload.map(article => article.id);
commit(types.CLEAR_ARTICLES);
commit(types.ADD_MANY_ARTICLES, payload);
commit(types.SET_ARTICLES_META, meta);
commit(types.ADD_MANY_ARTICLES_ID, articleIds);
return articleIds;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit, dispatch }, { portalSlug, ...articleObj }) => {
commit(types.SET_UI_FLAG, { isCreating: true });
try {
const { data } = await articlesAPI.createArticle({
portalSlug,
articleObj,
});
const payload = camelcaseKeys(data.payload);
const { id: articleId } = payload;
commit(types.ADD_ARTICLE, payload);
commit(types.ADD_ARTICLE_ID, articleId);
commit(types.ADD_ARTICLE_FLAG, articleId);
dispatch('portals/updatePortal', portalSlug, { root: true });
return articleId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isCreating: false });
}
},
show: async ({ commit }, { id, portalSlug }) => {
commit(types.SET_UI_FLAG, { isFetching: true });
try {
const { data } = await articlesAPI.getArticle({ id, portalSlug });
const payload = camelcaseKeys(data.payload);
const { id: articleId } = payload;
commit(types.ADD_ARTICLE, payload);
commit(types.ADD_ARTICLE_ID, articleId);
commit(types.SET_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
updateAsync: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { isUpdating: true },
articleId,
});
try {
await articlesAPI.updateArticle({ portalSlug, articleId, articleObj });
return articleId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: { isUpdating: false },
articleId,
});
}
},
update: async ({ commit }, { portalSlug, articleId, ...articleObj }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
isUpdating: true,
},
articleId,
});
try {
const { data } = await articlesAPI.updateArticle({
portalSlug,
articleId,
articleObj,
});
const payload = camelcaseKeys(data.payload);
commit(types.UPDATE_ARTICLE, payload);
return articleId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
isUpdating: false,
},
articleId,
});
}
},
updateArticleMeta: async ({ commit }, { portalSlug, locale }) => {
try {
const { data } = await articlesAPI.getArticles({
pageNumber: 1,
portalSlug,
locale,
});
const meta = camelcaseKeys(data.meta);
const { currentPage, ...metaWithoutCurrentPage } = meta;
commit(types.SET_ARTICLES_META, metaWithoutCurrentPage);
} catch (error) {
throwErrorMessage(error);
}
},
delete: async ({ commit }, { portalSlug, articleId }) => {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
isDeleting: true,
},
articleId,
});
try {
await articlesAPI.deleteArticle({ portalSlug, articleId });
commit(types.REMOVE_ARTICLE, articleId);
commit(types.REMOVE_ARTICLE_ID, articleId);
return articleId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.UPDATE_ARTICLE_FLAG, {
uiFlags: {
isDeleting: false,
},
articleId,
});
}
},
attachImage: async (_, { file }) => {
const { fileUrl } = await uploadFile(file);
return fileUrl;
},
uploadExternalImage: async (_, { url }) => {
const { fileUrl } = await uploadExternalImage(url);
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
try {
await articlesAPI.reorderArticles({
portalSlug,
reorderedGroup,
categorySlug,
});
} catch (error) {
throwErrorMessage(error);
}
return '';
},
};

View File

@@ -0,0 +1,36 @@
export const getters = {
uiFlags: state => helpCenterId => {
const uiFlags = state.articles.uiFlags.byId[helpCenterId];
if (uiFlags) return uiFlags;
return { isFetching: false, isUpdating: false, isDeleting: false };
},
isFetching: state => state.uiFlags.isFetching,
articleById:
(...getterArguments) =>
articleId => {
const [state] = getterArguments;
const article = state.articles.byId[articleId];
if (!article) return undefined;
return article;
},
allArticles: (...getterArguments) => {
const [state, _getters] = getterArguments;
const articles = state.articles.allIds
.map(id => {
return _getters.articleById(id);
})
.filter(article => article !== undefined);
return articles;
},
articleStatus:
(...getterArguments) =>
articleId => {
const [state] = getterArguments;
const article = state.articles.byId[articleId];
if (!article) return undefined;
return article.status;
},
getMeta: state => {
return state.meta;
},
};

View File

@@ -0,0 +1,34 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
export const defaultHelpCenterFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
};
const state = {
meta: {
count: 0,
currentPage: 1,
},
articles: {
byId: {},
allIds: [],
uiFlags: {
byId: {},
},
},
uiFlags: {
allFetched: false,
isFetching: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,90 @@
import types from '../../mutation-types';
export const mutations = {
[types.SET_UI_FLAG](_state, uiFlags) {
_state.uiFlags = {
..._state.uiFlags,
...uiFlags,
};
},
[types.ADD_ARTICLE]: ($state, article) => {
if (!article.id) return;
$state.articles.byId[article.id] = article;
},
[types.CLEAR_ARTICLES]: $state => {
$state.articles.allIds = [];
$state.articles.byId = {};
$state.articles.uiFlags.byId = {};
},
[types.ADD_MANY_ARTICLES]($state, articles) {
const allArticles = { ...$state.articles.byId };
articles.forEach(article => {
allArticles[article.id] = article;
});
$state.articles.byId = allArticles;
},
[types.ADD_MANY_ARTICLES_ID]($state, articleIds) {
$state.articles.allIds.push(...articleIds);
},
[types.SET_ARTICLES_META]: ($state, meta) => {
$state.meta = {
...$state.meta,
...meta,
};
},
[types.ADD_ARTICLE_ID]: ($state, articleId) => {
if ($state.articles.allIds.includes(articleId)) return;
$state.articles.allIds.push(articleId);
},
[types.UPDATE_ARTICLE_FLAG]: ($state, { articleId, uiFlags }) => {
const flags = $state.articles.uiFlags.byId[articleId] || {};
$state.articles.uiFlags.byId[articleId] = {
...{
isFetching: false,
isUpdating: false,
isDeleting: false,
},
...flags,
...uiFlags,
};
},
[types.ADD_ARTICLE_FLAG]: ($state, { articleId, uiFlags }) => {
$state.articles.uiFlags.byId[articleId] = {
...{
isFetching: false,
isUpdating: false,
isDeleting: false,
},
...uiFlags,
};
},
[types.UPDATE_ARTICLE]: ($state, updatedArticle) => {
const articleId = updatedArticle.id;
if ($state.articles.byId[articleId]) {
// Preserve the original position
const originalPosition = $state.articles.byId[articleId].position;
// Update the article, keeping the original position
// This is not moved out of the original position when we update the article
$state.articles.byId[articleId] = {
...updatedArticle,
position: originalPosition,
};
}
},
[types.REMOVE_ARTICLE]($state, articleId) {
const { [articleId]: toBeRemoved, ...newById } = $state.articles.byId;
$state.articles.byId = newById;
},
[types.REMOVE_ARTICLE_ID]($state, articleId) {
$state.articles.allIds = $state.articles.allIds.filter(
id => id !== articleId
);
},
};

View File

@@ -0,0 +1,282 @@
import axios from 'axios';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import * as types from '../../../mutation-types';
import { actions } from '../actions';
vi.mock('dashboard/helper/uploadHelper');
const articleList = [
{
id: 1,
category_id: 1,
title: 'Documents are required to complete KYC',
},
];
const camelCasedArticle = {
id: 1,
categoryId: 1,
title: 'Documents are required to complete KYC',
};
const commit = vi.fn();
const dispatch = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
payload: articleList,
meta: {
current_page: '1',
articles_count: 5,
},
},
});
await actions.index(
{ commit },
{ pageNumber: 1, portalSlug: 'test', locale: 'en' }
);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.CLEAR_ARTICLES],
[
types.default.ADD_MANY_ARTICLES,
[
{
id: 1,
categoryId: 1,
title: 'Documents are required to complete KYC',
},
],
],
[
types.default.SET_ARTICLES_META,
{ currentPage: '1', articlesCount: 5 },
],
[types.default.ADD_MANY_ARTICLES_ID, [1]],
[types.default.SET_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.index(
{ commit },
{ pageNumber: 1, portalSlug: 'test', locale: 'en' }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: { payload: camelCasedArticle } });
await actions.create({ commit, dispatch }, camelCasedArticle);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isCreating: true }],
[types.default.ADD_ARTICLE, camelCasedArticle],
[types.default.ADD_ARTICLE_ID, 1],
[types.default.ADD_ARTICLE_FLAG, 1],
[types.default.SET_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.create({ commit }, articleList[0])).rejects.toThrow(
Error
);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isCreating: true }],
[types.default.SET_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: { payload: camelCasedArticle } });
await actions.update(
{ commit },
{
portalSlug: 'room-rental',
articleId: 1,
title: 'Documents are required to complete KYC',
}
);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: true }, articleId: 1 },
],
[types.default.UPDATE_ARTICLE, camelCasedArticle],
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: false }, articleId: 1 },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update(
{ commit },
{
portalSlug: 'room-rental',
articleId: 1,
title: 'Documents are required to complete KYC',
}
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: true }, articleId: 1 },
],
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isUpdating: false }, articleId: 1 },
],
]);
});
});
describe('#updateArticleMeta', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: {
payload: articleList,
meta: {
all_articles_count: 56,
archived_articles_count: 7,
articles_count: 56,
current_page: '1', // This is not needed, it cause pagination issues.
draft_articles_count: 24,
mine_articles_count: 44,
published_count: 25,
},
},
});
await actions.updateArticleMeta(
{ commit },
{ pageNumber: 1, portalSlug: 'test', locale: 'en' }
);
expect(commit.mock.calls).toEqual([
[
types.default.SET_ARTICLES_META,
{
allArticlesCount: 56,
archivedArticlesCount: 7,
articlesCount: 56,
draftArticlesCount: 24,
mineArticlesCount: 44,
publishedCount: 25,
},
],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: articleList[0] });
await actions.delete(
{ commit },
{ portalSlug: 'test', articleId: articleList[0].id }
);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: true }, articleId: 1 },
],
[types.default.REMOVE_ARTICLE, articleList[0].id],
[types.default.REMOVE_ARTICLE_ID, articleList[0].id],
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: false }, articleId: 1 },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete(
{ commit },
{ portalSlug: 'test', articleId: articleList[0].id }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: true }, articleId: 1 },
],
[
types.default.UPDATE_ARTICLE_FLAG,
{ uiFlags: { isDeleting: false }, articleId: 1 },
],
]);
});
});
describe('attachImage', () => {
it('should upload the file and return the fileUrl', async () => {
const mockFile = new Blob(['test'], { type: 'image/png' });
mockFile.name = 'test.png';
const mockFileUrl = 'https://test.com/test.png';
uploadFile.mockResolvedValueOnce({ fileUrl: mockFileUrl });
const result = await actions.attachImage({}, { file: mockFile });
expect(uploadFile).toHaveBeenCalledWith(mockFile);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
const mockFile = new Blob(['test'], { type: 'image/png' });
mockFile.name = 'test.png';
const mockError = new Error('Upload failed');
uploadFile.mockRejectedValueOnce(mockError);
await expect(actions.attachImage({}, { file: mockFile })).rejects.toThrow(
'Upload failed'
);
});
});
describe('uploadExternalImage', () => {
it('should upload the image from external URL and return the fileUrl', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockFileUrl = 'https://uploaded.example.com/image.jpg';
uploadExternalImage.mockResolvedValueOnce({ fileUrl: mockFileUrl });
// When
const result = await actions.uploadExternalImage({}, { url: mockUrl });
// Then
expect(uploadExternalImage).toHaveBeenCalledWith(mockUrl);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Upload failed');
uploadExternalImage.mockRejectedValueOnce(mockError);
await expect(
actions.uploadExternalImage({}, { url: mockUrl })
).rejects.toThrow('Upload failed');
});
});
});

View File

@@ -0,0 +1,57 @@
export default {
meta: {
count: 123,
currentPage: 2,
},
articles: {
byId: {
1: {
id: 1,
category_id: 1,
title: 'Documents are required to complete KYC',
content:
'The submission of the following documents is mandatory to complete registration, ID proof - PAN Card, Address proof',
description: 'Documents are required to complete KYC',
status: 'draft',
account_id: 1,
views: 122,
author: {
id: 5,
account_id: 1,
email: 'tom@furrent.com',
available_name: 'Tom',
name: 'Tom Jose',
},
},
2: {
id: 2,
category_id: 1,
title:
'How do I change my registered email address and/or phone number?',
content:
'Kindly login to your Furrent account to chat with us or submit a request and we would be glad to help you update the contact details on your account.',
description: 'Change my registered email address and/or phone number',
status: 'draft',
account_id: 1,
views: 121,
author: {
id: 5,
account_id: 1,
email: 'tom@furrent.com',
available_name: 'Tom',
name: 'Tom Jose',
},
},
},
allIds: [1, 2],
uiFlags: {
byId: {
1: { isFetching: false, isUpdating: true, isDeleting: false },
},
},
},
uiFlags: {
allFetched: false,
isFetching: true,
},
};

View File

@@ -0,0 +1,44 @@
import { getters } from '../getters';
import articles from './fixtures';
describe('#getters', () => {
let state = {};
beforeEach(() => {
state = articles;
});
it('uiFlags', () => {
expect(getters.uiFlags(state)(1)).toEqual({
isFetching: false,
isUpdating: true,
isDeleting: false,
});
});
it('articleById', () => {
expect(getters.articleById(state)(1)).toEqual({
id: 1,
category_id: 1,
title: 'Documents are required to complete KYC',
content:
'The submission of the following documents is mandatory to complete registration, ID proof - PAN Card, Address proof',
description: 'Documents are required to complete KYC',
status: 'draft',
account_id: 1,
views: 122,
author: {
id: 5,
account_id: 1,
email: 'tom@furrent.com',
available_name: 'Tom',
name: 'Tom Jose',
},
});
});
it('articleStatus', () => {
expect(getters.articleStatus(state)(1)).toEqual('draft');
});
it('isFetchingArticles', () => {
expect(getters.isFetching(state)).toEqual(true);
});
});

View File

@@ -0,0 +1,157 @@
import { mutations } from '../mutations';
import article from './fixtures';
import types from '../../../mutation-types';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = article;
});
describe('#SET_UI_FLAG', () => {
it('It returns default flags if empty object passed', () => {
mutations[types.SET_UI_FLAG](state, {});
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: true,
});
});
it('Update flags when flag passed as parameters', () => {
mutations[types.SET_UI_FLAG](state, { isFetching: true });
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: true,
});
});
});
describe('#ADD_ARTICLE', () => {
it('add valid article to state', () => {
mutations[types.ADD_ARTICLE](state, {
id: 3,
category_id: 1,
title:
'How do I change my registered email address and/or phone number?',
});
expect(state.articles.byId[3]).toEqual({
id: 3,
category_id: 1,
title:
'How do I change my registered email address and/or phone number?',
});
});
it('does not add article with empty data passed', () => {
mutations[types.ADD_ARTICLE](state, {});
expect(state).toEqual(article);
});
});
describe('#ARTICLES_META', () => {
beforeEach(() => {
state.meta = {};
});
it('add meta to state', () => {
mutations[types.SET_ARTICLES_META](state, {
articles_count: 3,
current_page: 1,
});
expect(state.meta).toEqual({
articles_count: 3,
current_page: 1,
});
});
it('preserves existing meta values and updates only provided keys', () => {
state.meta = {
all_articles_count: 56,
archived_articles_count: 5,
articles_count: 56,
current_page: '1',
draft_articles_count: 26,
published_count: 25,
};
mutations[types.SET_ARTICLES_META](state, {
articles_count: 3,
draft_articles_count: 27,
});
expect(state.meta).toEqual({
all_articles_count: 56,
archived_articles_count: 5,
current_page: '1',
articles_count: 3,
draft_articles_count: 27,
published_count: 25,
});
});
});
describe('#ADD_ARTICLE_ID', () => {
it('add valid article id to state', () => {
mutations[types.ADD_ARTICLE_ID](state, 3);
expect(state.articles.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid article with empty data passed', () => {
mutations[types.ADD_ARTICLE_ID](state, {});
expect(state).toEqual(article);
});
});
describe('#UPDATE_ARTICLE', () => {
it('does not update if empty object is passed', () => {
mutations[types.UPDATE_ARTICLE](state, {});
expect(state).toEqual(article);
});
it('does not update if object id is not present in the state', () => {
mutations[types.UPDATE_ARTICLE](state, { id: 5 });
expect(state).toEqual(article);
});
it('updates if object with id is already present in the state', () => {
const updatedArticle = {
id: 2,
title: 'Updated Title',
content: 'Updated Content',
};
mutations[types.UPDATE_ARTICLE](state, updatedArticle);
expect(state.articles.byId[2].title).toEqual('Updated Title');
expect(state.articles.byId[2].content).toEqual('Updated Content');
});
it('preserves the original position when updating an article', () => {
const originalPosition = state.articles.byId[2].position;
const updatedArticle = {
id: 2,
title: 'Updated Title',
content: 'Updated Content',
};
mutations[types.UPDATE_ARTICLE](state, updatedArticle);
expect(state.articles.byId[2].position).toEqual(originalPosition);
});
});
describe('#REMOVE_ARTICLE', () => {
it('does not remove object entry if no id is passed', () => {
mutations[types.REMOVE_ARTICLE](state, undefined);
expect(state).toEqual({ ...article });
});
it('removes article if valid article id passed', () => {
mutations[types.REMOVE_ARTICLE](state, 2);
expect(state.articles.byId[2]).toEqual(undefined);
});
});
describe('#CLEAR_ARTICLES', () => {
it('clears articles', () => {
mutations[types.CLEAR_ARTICLES](state);
expect(state.articles.allIds).toEqual([]);
expect(state.articles.byId).toEqual({});
expect(state.articles.uiFlags).toEqual({
byId: {},
});
});
});
});

View File

@@ -0,0 +1,95 @@
import categoriesAPI from 'dashboard/api/helpCenter/categories.js';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import types from '../../mutation-types';
export const actions = {
index: async ({ commit }, { portalSlug, locale }) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
if (portalSlug) {
const {
data: { payload },
} = await categoriesAPI.get({ portalSlug, locale });
commit(types.CLEAR_CATEGORIES);
const categoryIds = payload.map(category => category.id);
commit(types.ADD_MANY_CATEGORIES, payload);
commit(types.ADD_MANY_CATEGORIES_ID, categoryIds);
return categoryIds;
}
return '';
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
create: async ({ commit }, { portalSlug, categoryObj }) => {
commit(types.SET_UI_FLAG, { isCreating: true });
try {
const {
data: { payload },
} = await categoriesAPI.create({ portalSlug, categoryObj });
const { id: categoryId } = payload;
commit(types.ADD_CATEGORY, payload);
commit(types.ADD_CATEGORY_ID, categoryId);
return categoryId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { portalSlug, categoryId, categoryObj }) => {
commit(types.ADD_CATEGORY_FLAG, {
uiFlags: {
isUpdating: true,
},
categoryId,
});
try {
const {
data: { payload },
} = await categoriesAPI.update({
portalSlug,
categoryId,
categoryObj,
});
commit(types.UPDATE_CATEGORY, payload);
return categoryId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.ADD_CATEGORY_FLAG, {
uiFlags: {
isUpdating: false,
},
categoryId,
});
}
},
delete: async ({ commit }, { portalSlug, categoryId }) => {
commit(types.ADD_CATEGORY_FLAG, {
uiFlags: {
isDeleting: true,
},
categoryId,
});
try {
await categoriesAPI.delete({ portalSlug, categoryId });
commit(types.REMOVE_CATEGORY, categoryId);
commit(types.REMOVE_CATEGORY_ID, categoryId);
return categoryId;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.ADD_CATEGORY_FLAG, {
uiFlags: {
isDeleting: false,
},
categoryId,
});
}
},
};

View File

@@ -0,0 +1,36 @@
export const getters = {
uiFlags: state => helpCenterId => {
const uiFlags = state.categories.uiFlags.byId[helpCenterId];
if (uiFlags) return uiFlags;
return { isFetching: false, isUpdating: false, isDeleting: false };
},
isFetching: state => state.uiFlags.isFetching,
isCreating: state => state.uiFlags.isCreating,
categoryById:
(...getterArguments) =>
categoryId => {
const [state] = getterArguments;
const category = state.categories.byId[categoryId];
if (!category) return undefined;
return category;
},
allCategories: (...getterArguments) => {
const [state, _getters] = getterArguments;
const categories = state.categories.allIds.map(id => {
return _getters.categoryById(id);
});
return categories;
},
categoriesByLocaleCode:
(...getterArguments) =>
localeCode => {
const [state, _getters] = getterArguments;
const categories = state.categories.allIds.map(id => {
return _getters.categoryById(id);
});
return categories.filter(category => category.locale === localeCode);
},
getMeta: state => {
return state.meta;
},
};

View File

@@ -0,0 +1,32 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
export const defaultHelpCenterFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
};
const state = {
categories: {
byId: {},
byLocale: {},
allIds: [],
uiFlags: {
byId: {},
},
},
uiFlags: {
allFetched: false,
isFetching: false,
isCreating: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,68 @@
import types from '../../mutation-types';
export const mutations = {
[types.SET_UI_FLAG](_state, uiFlags) {
_state.uiFlags = {
..._state.uiFlags,
...uiFlags,
};
},
[types.ADD_CATEGORY]: ($state, category) => {
if (!category.id) return;
$state.categories.byId[category.id] = { ...category };
},
[types.CLEAR_CATEGORIES]: $state => {
$state.categories.byId = {};
$state.categories.allIds = [];
$state.categories.uiFlags.byId = {};
},
[types.ADD_MANY_CATEGORIES]($state, categories) {
const allCategories = { ...$state.categories.byId };
categories.forEach(category => {
allCategories[category.id] = category;
});
$state.categories.byId = allCategories;
},
[types.ADD_MANY_CATEGORIES_ID]($state, categoryIds) {
$state.categories.allIds.push(...categoryIds);
},
[types.SET_CATEGORIES_META]: ($state, data) => {
const { categories_count: count, current_page: currentPage } = data;
$state.meta = { ...$state.meta, count, currentPage };
},
[types.ADD_CATEGORY_ID]: ($state, categoryId) => {
$state.categories.allIds.push(categoryId);
},
[types.ADD_CATEGORY_FLAG]: ($state, { categoryId, uiFlags }) => {
const flags = $state.categories.uiFlags.byId[categoryId];
$state.categories.uiFlags.byId[categoryId] = {
...{
isFetching: false,
isUpdating: false,
isDeleting: false,
},
...flags,
...uiFlags,
};
},
[types.UPDATE_CATEGORY]($state, category) {
const categoryId = category.id;
if (!$state.categories.allIds.includes(categoryId)) return;
$state.categories.byId[categoryId] = { ...category };
},
[types.REMOVE_CATEGORY]($state, categoryId) {
const { [categoryId]: toBeRemoved, ...newById } = $state.categories.byId;
$state.categories.byId = newById;
},
[types.REMOVE_CATEGORY_ID]($state, categoryId) {
$state.categories.allIds = $state.categories.allIds.filter(
id => id !== categoryId
);
},
};

View File

@@ -0,0 +1,164 @@
import axios from 'axios';
import { actions } from '../actions';
import * as types from '../../../mutation-types';
import { categoriesPayload } from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: categoriesPayload });
await actions.index({ commit }, { portalSlug: 'room-rental' });
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.CLEAR_CATEGORIES],
[types.default.ADD_MANY_CATEGORIES, categoriesPayload.payload],
[types.default.ADD_MANY_CATEGORIES_ID, [1, 2]],
[types.default.SET_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.index({ commit }, { portalSlug: 'room-rental' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isFetching: true }],
[types.default.SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: categoriesPayload });
await actions.create({ commit }, categoriesPayload);
const { id: categoryId } = categoriesPayload;
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isCreating: true }],
[types.default.ADD_CATEGORY, categoriesPayload.payload],
[types.default.ADD_CATEGORY_ID, categoryId],
[types.default.SET_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create({ commit }, 'web-docs', categoriesPayload.payload[0])
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_UI_FLAG, { isCreating: true }],
[types.default.SET_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: categoriesPayload });
await actions.update(
{ commit },
{
portalSlug: 'room-rental',
categoryId: 1,
categoryObj: categoriesPayload.payload[0],
}
);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_CATEGORY_FLAG,
{
uiFlags: {
isUpdating: true,
},
categoryId: 1,
},
],
[types.default.UPDATE_CATEGORY, categoriesPayload.payload],
[
types.default.ADD_CATEGORY_FLAG,
{
uiFlags: {
isUpdating: false,
},
categoryId: 1,
},
],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update(
{ commit },
{
portalSlug: 'room-rental',
categoryId: 1,
categoryObj: categoriesPayload.payload[0],
}
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isUpdating: true }, categoryId: 1 },
],
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isUpdating: false }, categoryId: 1 },
],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: categoriesPayload });
await actions.delete(
{ commit },
{
portalSlug: 'room-rental',
categoryId: categoriesPayload.payload[0].id,
}
);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isDeleting: true }, categoryId: 1 },
],
[types.default.REMOVE_CATEGORY, categoriesPayload.payload[0].id],
[types.default.REMOVE_CATEGORY_ID, categoriesPayload.payload[0].id],
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isDeleting: false }, categoryId: 1 },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete(
{ commit },
{
portalSlug: 'room-rental',
categoryId: categoriesPayload.payload[0].id,
}
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isDeleting: true }, categoryId: 1 },
],
[
types.default.ADD_CATEGORY_FLAG,
{ uiFlags: { isDeleting: false }, categoryId: 1 },
],
]);
});
});
});

View File

@@ -0,0 +1,77 @@
export const categoriesPayload = {
payload: [
{
id: 1,
name: 'FAQs',
slug: 'faq',
locale: 'en',
description: 'This category is for FAQs',
position: 0,
account_id: 1,
meta: {
articles_count: 1,
},
},
{
id: 2,
name: 'Product updates',
slug: 'product-updates',
locale: 'en',
description: 'This category is for product updates',
position: 0,
account_id: 1,
meta: {
articles_count: 0,
},
},
],
meta: {
current_page: 1,
categories_count: 2,
},
};
export const categoriesState = {
meta: {
count: 123,
currentPage: 1,
},
categories: {
byId: {
1: {
id: 1,
name: 'FAQs',
slug: 'faq',
locale: 'en',
description: 'This category is for FAQs',
position: 0,
account_id: 1,
meta: {
articles_count: 1,
},
},
2: {
id: 2,
name: 'Product updates',
slug: 'product-updates',
locale: 'en',
description: 'This category is for product updates',
position: 0,
account_id: 1,
meta: {
articles_count: 0,
},
},
},
allIds: [1, 2],
uiFlags: {
byId: {
1: { isFetching: false, isUpdating: true, isDeleting: false },
},
},
},
uiFlags: {
allFetched: false,
isFetching: true,
},
};

View File

@@ -0,0 +1,28 @@
import { getters } from '../getters';
import { categoriesState } from './fixtures';
describe('#getters', () => {
let state = {};
beforeEach(() => {
state = categoriesState;
});
it('uiFlags', () => {
expect(getters.uiFlags(state)(1)).toEqual({
isFetching: false,
isUpdating: true,
isDeleting: false,
});
});
it('categoryById', () => {
expect(getters.categoryById(state)(1)).toEqual(
categoriesState.categories.byId[1]
);
});
it('categoriesByLocaleCode', () => {
expect(getters.categoriesByLocaleCode(state, getters)('en_US')).toEqual([]);
});
it('isFetchingCategories', () => {
expect(getters.isFetching(state)).toEqual(true);
});
});

View File

@@ -0,0 +1,101 @@
import { mutations } from '../mutations';
import types from '../../../mutation-types';
import { categoriesState, categoriesPayload } from './fixtures';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = categoriesState;
});
describe('#SET_UI_FLAG', () => {
it('It returns default flags if empty object passed', () => {
mutations[types.SET_UI_FLAG](state, {});
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: true,
});
});
it('Update flags when flag passed as parameters', () => {
mutations[types.SET_UI_FLAG](state, { isFetching: true });
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: true,
});
});
});
describe('#ADD_CATEGORY', () => {
it('add valid category to state', () => {
mutations[types.ADD_CATEGORY](state, categoriesPayload.payload[0]);
expect(state.categories.byId[1]).toEqual(categoriesPayload.payload[0]);
});
it('does not add category with empty data passed', () => {
mutations[types.ADD_CATEGORY](state, {});
expect(state).toEqual(categoriesState);
});
});
describe('#CATEGORIES_META', () => {
it('add meta to state', () => {
mutations[types.SET_CATEGORIES_META](state, {
categories_count: 3,
current_page: 1,
});
expect(state.meta).toEqual({
count: 3,
currentPage: 1,
});
});
});
describe('#ADD_CATEGORY_ID', () => {
it('add valid category id to state', () => {
mutations[types.ADD_CATEGORY_ID](state, 3);
expect(state.categories.allIds).toEqual([1, 2, 3]);
});
it('Does not invalid category with empty data passed', () => {
mutations[types.ADD_CATEGORY_ID](state, {});
expect(state).toEqual(categoriesState);
});
});
describe('#UPDATE_CATEGORY', () => {
it('does not updates if empty object is passed', () => {
mutations[types.UPDATE_CATEGORY](state, {});
expect(state).toEqual(categoriesState);
});
it('does not updates if object id is not present ', () => {
mutations[types.UPDATE_CATEGORY](state, { id: 5 });
expect(state).toEqual(categoriesState);
});
it(' updates if object with id already present in the state', () => {
mutations[types.UPDATE_CATEGORY](state, {
id: 2,
title: 'This category is for product updates',
});
expect(state.categories.byId[2].title).toEqual(
'This category is for product updates'
);
});
});
describe('#REMOVE_CATEGORY', () => {
it('does not remove object entry if no id is passed', () => {
mutations[types.REMOVE_CATEGORY](state, undefined);
expect(state).toEqual({ ...categoriesState });
});
it('removes category if valid category id passed', () => {
mutations[types.REMOVE_CATEGORY](state, 2);
expect(state.categories.byId[2]).toEqual(undefined);
});
});
// describe('#CLEAR_CATEGORIES', () => {
// it('clears categories', () => {
// mutations[types.CLEAR_CATEGORIES](state);
// expect(state.categories.allIds).toEqual([]);
// expect(state.categories.byId).toEqual({});
// expect(state.categories.uiFlags).toEqual({});
// });
// });
});

View File

@@ -0,0 +1,139 @@
import PortalAPI from 'dashboard/api/helpCenter/portals';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import { types } from './mutations';
const portalAPIs = new PortalAPI();
export const actions = {
index: async ({ commit }) => {
try {
commit(types.SET_UI_FLAG, { isFetching: true });
const {
data: { payload },
} = await portalAPIs.get();
commit(types.CLEAR_PORTALS);
const portalSlugs = payload.map(portal => portal.slug);
commit(types.ADD_MANY_PORTALS_ENTRY, payload);
commit(types.ADD_MANY_PORTALS_IDS, portalSlugs);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isFetching: false });
}
},
show: async ({ commit }, { portalSlug, locale }) => {
commit(types.SET_UI_FLAG, { isFetchingItem: true });
try {
const response = await portalAPIs.getPortal({ portalSlug, locale });
const {
data: { meta },
} = response;
commit(types.SET_PORTALS_META, meta);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_UI_FLAG, { isFetchingItem: false });
}
},
create: async ({ commit }, params) => {
commit(types.SET_UI_FLAG, { isCreating: true });
try {
const { data } = await portalAPIs.create(params);
const { slug: portalSlug } = data;
commit(types.ADD_PORTAL_ENTRY, data);
commit(types.ADD_PORTAL_ID, portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { isCreating: false });
}
},
update: async ({ commit }, { portalSlug, ...portalObj }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalSlug,
});
try {
const { data } = await portalAPIs.updatePortal({
portalSlug,
portalObj,
});
commit(types.UPDATE_PORTAL_ENTRY, data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalSlug,
});
}
},
delete: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isDeleting: true },
portalSlug,
});
try {
await portalAPIs.delete(portalSlug);
commit(types.REMOVE_PORTAL_ENTRY, portalSlug);
commit(types.REMOVE_PORTAL_ID, portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isDeleting: false },
portalSlug,
});
}
},
deleteLogo: async ({ commit }, { portalSlug }) => {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: true },
portalSlug,
});
try {
await portalAPIs.deleteLogo(portalSlug);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_HELP_PORTAL_UI_FLAG, {
uiFlags: { isUpdating: false },
portalSlug,
});
}
},
updatePortal: async ({ commit }, portal) => {
commit(types.UPDATE_PORTAL_ENTRY, portal);
},
switchPortal: async ({ commit }, isSwitching) => {
commit(types.SET_PORTAL_SWITCHING_FLAG, {
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 });
}
},
};

View File

@@ -0,0 +1,30 @@
export const getters = {
uiFlagsIn: state => portalId => {
const uiFlags = state.portals.uiFlags.byId[portalId];
if (uiFlags) return uiFlags;
return { isFetching: false, isUpdating: false, isDeleting: false };
},
isFetchingPortals: state => state.uiFlags.isFetching,
isCreatingPortal: state => state.uiFlags.isCreating,
isSwitchingPortal: state => state.uiFlags.isSwitching,
isFetchingSSLStatus: state => state.uiFlags.isFetchingSSLStatus,
portalBySlug:
(...getterArguments) =>
portalId => {
const [state] = getterArguments;
const portal = state.portals.byId[portalId];
return portal;
},
allPortals: (...getterArguments) => {
const [state, _getters] = getterArguments;
const portals = state.portals.allIds.map(id => {
return _getters.portalBySlug(id);
});
return portals;
},
count: state => state.portals.allIds.length || 0,
getMeta: state => state.meta,
isSwitching: state => state.isSwitching,
};

View File

@@ -0,0 +1,45 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
export const defaultPortalFlags = {
isFetching: false,
isUpdating: false,
isDeleting: false,
isFetchingSSLStatus: false,
};
const state = {
meta: {
allArticlesCount: 0,
mineArticlesCount: 0,
draftArticlesCount: 0,
archivedArticlesCount: 0,
},
portals: {
byId: {},
allIds: [],
uiFlags: {
byId: {
// 1: { isFetching: false, isUpdating: false, isDeleting: false },
},
},
meta: {
byId: {},
},
},
uiFlags: {
allFetched: false,
isFetching: false,
isSwitching: false,
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,128 @@
import { defaultPortalFlags } from './index';
export const types = {
SET_UI_FLAG: 'setUIFlag',
ADD_PORTAL_ENTRY: 'addPortalEntry',
SET_PORTALS_META: 'setPortalsMeta',
ADD_MANY_PORTALS_ENTRY: 'addManyPortalsEntry',
ADD_PORTAL_ID: 'addPortalId',
CLEAR_PORTALS: 'clearPortals',
ADD_MANY_PORTALS_IDS: 'addManyPortalsIds',
UPDATE_PORTAL_ENTRY: 'updatePortalEntry',
REMOVE_PORTAL_ENTRY: 'removePortalEntry',
REMOVE_PORTAL_ID: 'removePortalId',
SET_HELP_PORTAL_UI_FLAG: 'setHelpCenterUIFlag',
SET_PORTAL_SWITCHING_FLAG: 'setPortalSwitchingFlag',
SET_SSL_SETTINGS: 'setSSLSettings',
};
export const mutations = {
[types.SET_UI_FLAG]($state, uiFlags) {
$state.uiFlags = {
...$state.uiFlags,
...uiFlags,
};
},
[types.ADD_PORTAL_ENTRY]($state, portal) {
$state.portals.byId = {
...$state.portals.byId,
[portal.slug]: {
...portal,
},
};
},
[types.ADD_MANY_PORTALS_ENTRY]($state, portals) {
const allPortals = { ...$state.portals.byId };
portals.forEach(portal => {
allPortals[portal.slug] = portal;
});
$state.portals.byId = allPortals;
},
[types.CLEAR_PORTALS]: $state => {
$state.portals.byId = {};
$state.portals.allIds = [];
$state.portals.uiFlags.byId = {};
},
[types.SET_PORTALS_META]: ($state, data) => {
const {
all_articles_count: allArticlesCount = 0,
mine_articles_count: mineArticlesCount = 0,
draft_articles_count: draftArticlesCount = 0,
archived_articles_count: archivedArticlesCount = 0,
} = data;
$state.meta = {
...$state.meta,
allArticlesCount,
archivedArticlesCount,
mineArticlesCount,
draftArticlesCount,
};
},
[types.ADD_PORTAL_ID]($state, portalSlug) {
$state.portals.allIds.push(portalSlug);
},
[types.ADD_MANY_PORTALS_IDS]($state, portalSlugs) {
$state.portals.allIds.push(...portalSlugs);
},
[types.UPDATE_PORTAL_ENTRY]($state, portal) {
const portalSlug = portal.slug;
if (!$state.portals.allIds.includes(portalSlug)) return;
$state.portals.byId = {
...$state.portals.byId,
[portalSlug]: {
...portal,
},
};
},
[types.REMOVE_PORTAL_ENTRY]($state, portalSlug) {
if (!portalSlug) return;
const { [portalSlug]: toBeRemoved, ...newById } = $state.portals.byId;
$state.portals.byId = newById;
},
[types.REMOVE_PORTAL_ID]($state, portalSlug) {
$state.portals.allIds = $state.portals.allIds.filter(
slug => slug !== portalSlug
);
},
[types.SET_HELP_PORTAL_UI_FLAG]($state, { portalSlug, uiFlags }) {
const flags = $state.portals.uiFlags.byId[portalSlug];
$state.portals.uiFlags.byId = {
...$state.portals.uiFlags.byId,
[portalSlug]: {
...defaultPortalFlags,
...flags,
...uiFlags,
},
};
},
[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,
},
},
};
},
};

View File

@@ -0,0 +1,202 @@
import axios from 'axios';
import { actions } from '../actions';
import { types } from '../mutations';
import { apiResponse } from './fixtures';
const commit = vi.fn();
const dispatch = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#index', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: apiResponse });
await actions.index({
commit,
dispatch,
state: {},
});
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetching: true }],
[types.CLEAR_PORTALS],
[types.ADD_MANY_PORTALS_ENTRY, apiResponse.payload],
[types.ADD_MANY_PORTALS_IDS, ['domain', 'campaign']],
[types.SET_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.index({ commit })).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetching: true }],
[types.SET_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: apiResponse.payload[1] });
await actions.create(
{ commit, dispatch, state: { portals: {} } },
{
color: 'red',
custom_domain: 'domain_for_help',
header_text: 'Domain Header',
}
);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isCreating: true }],
[types.ADD_PORTAL_ENTRY, apiResponse.payload[1]],
[types.ADD_PORTAL_ID, 'campaign'],
[types.SET_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create({ commit, dispatch, state: { portals: {} } }, {})
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isCreating: true }],
[types.SET_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#show', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { meta: { all_articles_count: 1 } },
});
await actions.show(
{ commit },
{
portalSlug: 'handbook',
locale: 'en',
}
);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isFetchingItem: true }],
[types.SET_PORTALS_META, { all_articles_count: 1 }],
[types.SET_UI_FLAG, { isFetchingItem: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create({ commit, dispatch, state: { portals: {} } }, {})
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_UI_FLAG, { isCreating: true }],
[types.SET_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: apiResponse.payload[1] });
await actions.update(
{ commit },
{ portalObj: apiResponse.payload[1], portalSlug: 'campaign' }
);
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: true }, portalSlug: 'campaign' },
],
[types.UPDATE_PORTAL_ENTRY, apiResponse.payload[1]],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: false }, portalSlug: 'campaign' },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update(
{ commit },
{ portalObj: apiResponse.payload[1], portalSlug: 'campaign' }
)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: true }, portalSlug: 'campaign' },
],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isUpdating: false }, portalSlug: 'campaign' },
],
]);
});
});
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({});
await actions.delete({ commit }, { portalSlug: 'campaign' });
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: true }, portalSlug: 'campaign' },
],
[types.REMOVE_PORTAL_ENTRY, 'campaign'],
[types.REMOVE_PORTAL_ID, 'campaign'],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: false }, portalSlug: 'campaign' },
],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, { portalSlug: 'campaign' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: true }, portalSlug: 'campaign' },
],
[
types.SET_HELP_PORTAL_UI_FLAG,
{ uiFlags: { isDeleting: false }, portalSlug: 'campaign' },
],
]);
});
});
});

View File

@@ -0,0 +1,85 @@
export default {
meta: {
count: 0,
currentPage: 1,
},
portals: {
byId: {
1: {
id: 1,
color: 'red',
custom_domain: 'domain_for_help',
header_text: 'Domain Header',
homepage_link: 'help-center',
name: 'help name',
page_title: 'page title',
slug: 'domain',
archived: false,
config: {
allowed_locales: ['en'],
},
},
2: {
id: 2,
color: 'green',
custom_domain: 'campaign_for_help',
header_text: 'Campaign Header',
homepage_link: 'help-center',
name: 'help name',
page_title: 'campaign title',
slug: 'campaign',
archived: false,
config: {
allowed_locales: ['en'],
},
},
},
allIds: [1, 2],
uiFlags: {
byId: {
1: { isFetching: false, isUpdating: true, isDeleting: false },
},
},
},
uiFlags: {
allFetched: false,
isFetching: true,
},
};
export const apiResponse = {
payload: [
{
id: 1,
color: 'red',
custom_domain: 'domain_for_help',
header_text: 'Domain Header',
homepage_link: 'help-center',
name: 'help name',
page_title: 'page title',
slug: 'domain',
archived: false,
config: {
allowed_locales: ['en'],
},
},
{
id: 2,
color: 'green',
custom_domain: 'campaign_for_help',
header_text: 'Campaign Header',
homepage_link: 'help-center',
name: 'help name',
page_title: 'campaign title',
slug: 'campaign',
archived: false,
config: {
allowed_locales: ['en'],
},
},
],
meta: {
current_page: 1,
portals_count: 1,
},
};

View File

@@ -0,0 +1,50 @@
import { getters } from '../getters';
import portal from './fixtures';
describe('#getters', () => {
it('getUIFlagsIn', () => {
const state = portal;
expect(getters.uiFlagsIn(state)(1)).toEqual({
isFetching: false,
isUpdating: true,
isDeleting: false,
});
});
it('isFetchingPortals', () => {
const state = portal;
expect(getters.isFetchingPortals(state)).toEqual(true);
});
it('portalBySlug', () => {
const state = portal;
expect(getters.portalBySlug(state)(1)).toEqual({
id: 1,
color: 'red',
custom_domain: 'domain_for_help',
header_text: 'Domain Header',
homepage_link: 'help-center',
name: 'help name',
page_title: 'page title',
slug: 'domain',
archived: false,
config: {
allowed_locales: ['en'],
},
});
});
it('allPortals', () => {
const state = portal;
expect(getters.allPortals(state, getters).length).toEqual(2);
});
it('count', () => {
const state = portal;
expect(getters.count(state)).toEqual(2);
});
it('getMeta', () => {
const state = portal;
expect(getters.getMeta(state)).toEqual({ count: 0, currentPage: 1 });
});
});

View File

@@ -0,0 +1,146 @@
import { mutations, types } from '../mutations';
import portal from './fixtures';
describe('#mutations', () => {
let state = {};
beforeEach(() => {
state = { ...portal };
});
describe('#SET_UI_FLAG', () => {
it('It returns default flags if empty object passed', () => {
mutations[types.SET_UI_FLAG](state, {});
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: true,
});
});
it('It updates keys when passed as parameters', () => {
mutations[types.SET_UI_FLAG](state, { isFetching: false });
expect(state.uiFlags).toEqual({
allFetched: false,
isFetching: false,
});
});
});
describe('[types.ADD_PORTAL_ENTRY]', () => {
it('does not add empty objects to state', () => {
mutations[types.ADD_PORTAL_ENTRY](state, {});
expect(state).toEqual(portal);
});
it('does adds helpcenter object to state', () => {
mutations[types.ADD_PORTAL_ENTRY](state, { slug: 'new' });
expect(state.portals.byId.new).toEqual({ slug: 'new' });
});
});
describe('[types.ADD_PORTAL_ID]', () => {
it('adds helpcenter slug to state', () => {
mutations[types.ADD_PORTAL_ID](state, 12);
expect(state.portals.allIds).toEqual([1, 2, 12]);
});
});
describe('[types.UPDATE_PORTAL_ENTRY]', () => {
it('does not updates if empty object is passed', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, {});
expect(state).toEqual(portal);
});
it('does not updates if object slug is not present ', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, { slug: 5 });
expect(state).toEqual(portal);
});
it(' updates if object with slug already present in the state', () => {
mutations[types.UPDATE_PORTAL_ENTRY](state, {
slug: 2,
name: 'Updated name',
});
expect(state.portals.byId[2].name).toEqual('Updated name');
});
});
describe('[types.REMOVE_PORTAL_ENTRY]', () => {
it('does not remove object entry if no slug is passed', () => {
mutations[types.REMOVE_PORTAL_ENTRY](state, undefined);
expect(state).toEqual({ ...portal });
});
it('removes object entry with to conversation if outgoing', () => {
mutations[types.REMOVE_PORTAL_ENTRY](state, 2);
expect(state.portals.byId[2]).toEqual(undefined);
});
});
describe('[types.REMOVE_PORTAL_ID]', () => {
it('removes slug from state', () => {
mutations[types.REMOVE_PORTAL_ID](state, 2);
expect(state.portals.allIds).toEqual([1, 12]);
});
});
describe('[types.SET_HELP_PORTAL_UI_FLAG]', () => {
it('sets correct flag in state', () => {
mutations[types.SET_HELP_PORTAL_UI_FLAG](state, {
portalSlug: 'domain',
uiFlags: { isFetching: true },
});
expect(state.portals.uiFlags.byId.domain).toEqual({
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'],
});
});
});
describe('#CLEAR_PORTALS', () => {
it('clears portals', () => {
mutations[types.CLEAR_PORTALS](state);
expect(state.portals.allIds).toEqual([]);
expect(state.portals.byId).toEqual({});
expect(state.portals.uiFlags).toEqual({
byId: {},
});
});
});
describe('#SET_PORTALS_META', () => {
it('add meta to state', () => {
mutations[types.SET_PORTALS_META](state, {
count: 10,
currentPage: 1,
all_articles_count: 10,
archived_articles_count: 10,
draft_articles_count: 10,
mine_articles_count: 10,
});
expect(state.meta).toEqual({
count: 0,
currentPage: 1,
allArticlesCount: 10,
archivedArticlesCount: 10,
draftArticlesCount: 10,
mineArticlesCount: 10,
});
});
});
});

View File

@@ -0,0 +1,66 @@
import AssignableAgentsAPI from '../../api/assignableAgents';
const state = {
records: {},
uiFlags: {
isFetching: false,
},
};
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
getAssignableAgents: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, inboxIds) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get(inboxIds);
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false });
}
},
};
export const mutations = {
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.SET_INBOX_ASSIGNABLE_AGENTS]: ($state, { inboxId, members }) => {
$state.records = {
...$state.records,
[inboxId]: members,
};
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,15 @@
import InboxMembersAPI from '../../api/inboxMembers';
export const actions = {
get(_, { inboxId }) {
return InboxMembersAPI.show(inboxId);
},
create(_, { inboxId, agentList }) {
return InboxMembersAPI.update({ inboxId, agentList });
},
};
export default {
namespaced: true,
actions,
};

View File

@@ -0,0 +1,382 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
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';
import { channelActions, buildInboxData } from './inboxes/channelActions';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isUpdating: false,
isDeleting: false,
isUpdatingIMAP: false,
isUpdatingSMTP: false,
},
};
export const getters = {
getInboxes($state) {
return $state.records;
},
getAllInboxes($state) {
return camelcaseKeys($state.records, { deep: true });
},
getWhatsAppTemplates: $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 messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
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 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 authentication templates
if (template.category === 'AUTHENTICATION') {
return false;
}
// Filter out CSAT templates (customer_satisfaction_survey and its versions)
if (
template.name &&
template.name.startsWith('customer_satisfaction_survey')
) {
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 => {
const { channel_type: channelType, phone_number: phoneNumber = '' } =
inbox;
const isEmailChannel = channelType === INBOX_TYPES.EMAIL;
const isSmsChannel =
channelType === INBOX_TYPES.TWILIO &&
phoneNumber.startsWith('whatsapp');
return isEmailChannel || isSmsChannel;
});
},
getInbox: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
return inbox || {};
},
getInboxById: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
return camelcaseKeys(inbox || {}, { deep: true });
},
getUIFlags($state) {
return $state.uiFlags;
},
getWebsiteInboxes($state) {
return $state.records.filter(item => item.channel_type === INBOX_TYPES.WEB);
},
getTwilioInboxes($state) {
return $state.records.filter(
item => item.channel_type === INBOX_TYPES.TWILIO
);
},
getSMSInboxes($state) {
return $state.records.filter(
item =>
item.channel_type === INBOX_TYPES.SMS ||
(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
);
},
getTiktokInboxByBusinessId: $state => businessId => {
return $state.records.find(
item =>
item.business_id === businessId &&
item.channel_type === INBOX_TYPES.TIKTOK
);
},
};
const sendAnalyticsEvent = channelType => {
AnalyticsHelper.track(ACCOUNT_EVENTS.ADDED_AN_INBOX, {
channelType,
});
};
export const actions = {
revalidate: async ({ commit }, { newKey }) => {
try {
const isExistingKeyValid = await InboxesAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await InboxesAPI.refetchAndCommit(newKey);
commit(types.default.SET_INBOXES, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async ({ commit }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: true });
try {
const response = await InboxesAPI.get(true);
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
commit(types.default.SET_INBOXES, response.data.payload);
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
}
},
createChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WebChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
const { channel = {} } = params;
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
const errorMessage = error?.response?.data?.message;
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw new Error(errorMessage);
}
},
createWebsiteChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await WebChannel.create(buildInboxData(params));
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('website');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
return throwErrorMessage(error);
}
},
createTwilioChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await TwilioChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('twilio');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw error;
}
},
createFBChannel: async ({ commit }, params) => {
try {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: true });
const response = await FBChannel.create(params);
commit(types.default.ADD_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
sendAnalyticsEvent('facebook');
return response.data;
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
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 {
const response = await InboxesAPI.update(
id,
formData ? buildInboxData(inboxParams) : inboxParams
);
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdating: false });
throwErrorMessage(error);
}
},
updateInboxIMAP: async ({ commit }, { id, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: true });
try {
const response = await InboxesAPI.update(id, inboxParams);
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingIMAP: false });
throwErrorMessage(error);
}
},
updateInboxSMTP: async ({ commit }, { id, ...inboxParams }) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: true });
try {
const response = await InboxesAPI.update(id, inboxParams);
commit(types.default.EDIT_INBOXES, response.data);
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isUpdatingSMTP: false });
throwErrorMessage(error);
}
},
delete: async ({ commit }, inboxId) => {
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: true });
try {
await InboxesAPI.delete(inboxId);
commit(types.default.DELETE_INBOXES, inboxId);
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.default.SET_INBOXES_UI_FLAG, { isDeleting: false });
throw new Error(error);
}
},
reauthorizeFacebookPage: async ({ commit }, params) => {
try {
const response = await FBChannel.reauthorizeFacebookPage(params);
commit(types.default.EDIT_INBOXES, response.data);
} catch (error) {
throw new Error(error.message);
}
},
deleteInboxAvatar: async (_, inboxId) => {
try {
await InboxesAPI.deleteInboxAvatar(inboxId);
} catch (error) {
throw new Error(error);
}
},
syncTemplates: async (_, inboxId) => {
try {
await InboxesAPI.syncTemplates(inboxId);
} catch (error) {
throw new Error(error);
}
},
createCSATTemplate: async (_, { inboxId, template }) => {
const response = await InboxesAPI.createCSATTemplate(inboxId, template);
return response.data;
},
getCSATTemplateStatus: async (_, { inboxId }) => {
const response = await InboxesAPI.getCSATTemplateStatus(inboxId);
return response.data;
},
};
export const mutations = {
[types.default.SET_INBOXES_UI_FLAG]($state, uiFlag) {
$state.uiFlags = { ...$state.uiFlags, ...uiFlag };
},
[types.default.SET_INBOXES]: MutationHelpers.set,
[types.default.SET_INBOXES_ITEM]: MutationHelpers.setSingleRecord,
[types.default.ADD_INBOXES]: MutationHelpers.create,
[types.default.EDIT_INBOXES]: MutationHelpers.update,
[types.default.DELETE_INBOXES]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

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

View File

@@ -0,0 +1,177 @@
/* eslint no-param-reassign: 0 */
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import IntegrationsAPI from '../../api/integrations';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const state = {
records: [],
uiFlags: {
isCreating: false,
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isCreatingHook: false,
isDeletingHook: false,
isCreatingSlack: false,
isUpdatingSlack: false,
isFetchingSlackChannels: false,
},
};
export const getters = {
getAppIntegrations($state) {
return $state.records;
},
getIntegration:
$state =>
(integrationId, defaultValue = {}) => {
const [integration] = $state.records.filter(
record => record.id === integrationId
);
return integration || defaultValue;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
get: async ({ commit }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: true });
try {
const response = await IntegrationsAPI.get();
commit(types.default.SET_INTEGRATIONS, response.data.payload);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetching: false });
}
},
connectSlack: async ({ commit }, code) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingSlack: true });
try {
const response = await IntegrationsAPI.connectSlack(code);
commit(types.default.ADD_INTEGRATION, response.data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, {
isCreatingSlack: false,
});
}
},
updateSlack: async ({ commit }, slackObj) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isUpdatingSlack: true });
try {
const response = await IntegrationsAPI.updateSlack(slackObj);
commit(types.default.ADD_INTEGRATION, response.data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, {
isUpdatingSlack: false,
});
}
},
listAllSlackChannels: async ({ commit }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, {
isFetchingSlackChannels: true,
});
try {
const response = await IntegrationsAPI.listAllSlackChannels();
return response.data;
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, {
isFetchingSlackChannels: false,
});
}
return null;
},
deleteIntegration: async ({ commit }, integrationId) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: true });
try {
await IntegrationsAPI.delete(integrationId);
commit(types.default.DELETE_INTEGRATION, {
id: integrationId,
enabled: false,
});
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeleting: false });
}
},
showHook: async ({ commit }, hookId) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetchingItem: true });
try {
const response = await IntegrationsAPI.showHook(hookId);
commit(types.default.ADD_INTEGRATION_HOOKS, response.data);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetchingItem: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isFetchingItem: false });
throw new Error(error);
}
},
createHook: async ({ commit }, hookData) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: true });
try {
const response = await IntegrationsAPI.createHook(hookData);
commit(types.default.ADD_INTEGRATION_HOOKS, response.data);
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isCreatingHook: false });
throw new Error(error);
}
},
deleteHook: async ({ commit }, { appId, hookId }) => {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: true });
try {
await IntegrationsAPI.deleteHook(hookId);
commit(types.default.DELETE_INTEGRATION_HOOKS, { appId, hookId });
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false });
} catch (error) {
commit(types.default.SET_INTEGRATIONS_UI_FLAG, { isDeletingHook: false });
throw new Error(error);
}
},
};
export const mutations = {
[types.default.SET_INTEGRATIONS_UI_FLAG]($state, uiFlag) {
$state.uiFlags = { ...$state.uiFlags, ...uiFlag };
},
[types.default.SET_INTEGRATIONS]: MutationHelpers.set,
[types.default.ADD_INTEGRATION]: MutationHelpers.updateAttributes,
[types.default.DELETE_INTEGRATION]: MutationHelpers.updateAttributes,
[types.default.ADD_INTEGRATION_HOOKS]: ($state, data) => {
$state.records = $state.records.map(record => {
if (record.id === data.app_id) {
return {
...record,
hooks: [...record.hooks, data],
};
}
return record;
});
},
[types.default.DELETE_INTEGRATION_HOOKS]: ($state, { appId, hookId }) => {
$state.records = $state.records.map(record => {
if (record.id === appId) {
return {
...record,
hooks: record.hooks.filter(hook => hook.id !== hookId),
};
}
return record;
});
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,123 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import LabelsAPI from '../../api/labels';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { LABEL_EVENTS } from '../../helper/AnalyticsHelper/events';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isDeleting: false,
},
};
export const getters = {
getLabels(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
getLabelsOnSidebar(_state) {
return _state.records
.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 = {
revalidate: async function revalidate({ commit }, { newKey }) {
try {
const isExistingKeyValid = await LabelsAPI.validateCacheKey(newKey);
if (!isExistingKeyValid) {
const response = await LabelsAPI.refetchAndCommit(newKey);
commit(types.SET_LABELS, response.data.payload);
}
} catch (error) {
// Ignore error
}
},
get: async function getLabels({ commit }) {
commit(types.SET_LABEL_UI_FLAG, { isFetching: true });
try {
const response = await LabelsAPI.get(true);
const sortedLabels = response.data.payload.sort((a, b) =>
a.title.localeCompare(b.title)
);
commit(types.SET_LABELS, sortedLabels);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_LABEL_UI_FLAG, { isFetching: false });
}
},
create: async function createLabels({ commit }, cannedObj) {
commit(types.SET_LABEL_UI_FLAG, { isCreating: true });
try {
const response = await LabelsAPI.create(cannedObj);
AnalyticsHelper.track(LABEL_EVENTS.CREATE);
commit(types.ADD_LABEL, response.data);
} catch (error) {
const errorMessage = error?.response?.data?.message;
throw new Error(errorMessage);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isCreating: false });
}
},
update: async function updateLabels({ commit }, { id, ...updateObj }) {
commit(types.SET_LABEL_UI_FLAG, { isUpdating: true });
try {
const response = await LabelsAPI.update(id, updateObj);
AnalyticsHelper.track(LABEL_EVENTS.UPDATE);
commit(types.EDIT_LABEL, response.data);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isUpdating: false });
}
},
delete: async function deleteLabels({ commit }, id) {
commit(types.SET_LABEL_UI_FLAG, { isDeleting: true });
try {
await LabelsAPI.delete(id);
AnalyticsHelper.track(LABEL_EVENTS.DELETED);
commit(types.DELETE_LABEL, id);
} catch (error) {
throw new Error(error);
} finally {
commit(types.SET_LABEL_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_LABEL_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_LABELS]: MutationHelpers.set,
[types.ADD_LABEL]: MutationHelpers.create,
[types.EDIT_LABEL]: MutationHelpers.update,
[types.DELETE_LABEL]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,117 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import MacrosAPI from '../../api/macros';
import { throwErrorMessage } from '../utils/api';
export const state = {
records: [],
uiFlags: {
isFetchingItem: false,
isFetching: false,
isCreating: false,
isDeleting: false,
isUpdating: false,
isExecuting: false,
},
};
export const getters = {
getMacros($state) {
return $state.records;
},
getMacro: $state => id => {
return $state.records.find(record => record.id === Number(id));
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
get: async function getMacros({ commit }) {
commit(types.SET_MACROS_UI_FLAG, { isFetching: true });
try {
const response = await MacrosAPI.get();
commit(types.SET_MACROS, response.data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_MACROS_UI_FLAG, { isFetching: false });
}
},
getSingleMacro: async function getMacroById({ commit }, macroId) {
commit(types.SET_MACROS_UI_FLAG, { isFetchingItem: true });
try {
const response = await MacrosAPI.show(macroId);
commit(types.ADD_MACRO, response.data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_MACROS_UI_FLAG, { isFetchingItem: false });
}
},
create: async function createMacro({ commit }, macrosObj) {
commit(types.SET_MACROS_UI_FLAG, { isCreating: true });
try {
const response = await MacrosAPI.create(macrosObj);
commit(types.ADD_MACRO, response.data.payload);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_MACROS_UI_FLAG, { isCreating: false });
}
},
execute: async function executeMacro({ commit }, macrosObj) {
commit(types.SET_MACROS_UI_FLAG, { isExecuting: true });
try {
await MacrosAPI.executeMacro(macrosObj);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_MACROS_UI_FLAG, { isExecuting: false });
}
},
update: async ({ commit }, { id, ...updateObj }) => {
commit(types.SET_MACROS_UI_FLAG, { isUpdating: true });
try {
const response = await MacrosAPI.update(id, updateObj);
commit(types.EDIT_MACRO, response.data.payload);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_MACROS_UI_FLAG, { isUpdating: false });
}
},
delete: async ({ commit }, id) => {
commit(types.SET_MACROS_UI_FLAG, { isDeleting: true });
try {
await MacrosAPI.delete(id);
commit(types.DELETE_MACRO, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_MACROS_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_MACROS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.ADD_MACRO]: MutationHelpers.setSingleRecord,
[types.SET_MACROS]: MutationHelpers.set,
[types.EDIT_MACRO]: MutationHelpers.update,
[types.DELETE_MACRO]: MutationHelpers.destroy,
};
export default {
namespaced: true,
actions,
state,
getters,
mutations,
};

View File

@@ -0,0 +1,169 @@
import types from '../../mutation-types';
import NotificationsAPI from '../../../api/notifications';
export const actions = {
get: async ({ commit }, { page = 1 } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get({ page });
commit(types.CLEAR_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
index: async ({ commit }, { page = 1, status, type, sortOrder } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: true });
try {
const {
data: {
data: { payload, meta },
},
} = await NotificationsAPI.get({
page,
status,
type,
sortOrder,
});
commit(types.SET_NOTIFICATIONS, payload);
commit(types.SET_NOTIFICATIONS_META, meta);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
if (payload.length < 15) {
commit(types.SET_ALL_NOTIFICATIONS_LOADED);
}
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isFetching: false });
}
},
unReadCount: async ({ commit } = {}) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: true });
try {
const { data } = await NotificationsAPI.getUnreadCount();
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, data);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdatingUnreadCount: false });
}
},
read: async (
{ commit },
{ id, primaryActorType, primaryActorId, unreadCount }
) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.read(primaryActorType, primaryActorId);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.READ_NOTIFICATION, { id, read_at: new Date() });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
unread: async ({ commit }, { id }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.unRead(id);
commit(types.READ_NOTIFICATION, { id, read_at: null });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
readAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
await NotificationsAPI.readAll();
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, 0);
commit(types.UPDATE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
throw new Error(error);
}
},
delete: async ({ commit }, { notification, count, unreadCount }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.delete(notification.id);
commit(types.SET_NOTIFICATIONS_UNREAD_COUNT, unreadCount - 1);
commit(types.DELETE_NOTIFICATION, { notification, count, unreadCount });
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAllRead: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'read',
});
commit(types.DELETE_READ_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
deleteAll: async ({ commit }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: true });
try {
await NotificationsAPI.deleteAll({
type: 'all',
});
commit(types.DELETE_ALL_NOTIFICATIONS);
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isDeleting: false });
}
},
snooze: async ({ commit }, { id, snoozedUntil }) => {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: true });
try {
const response = await NotificationsAPI.snooze({
id,
snoozedUntil,
});
const {
data: { snoozed_until = null },
} = response;
commit(types.SNOOZE_NOTIFICATION, {
id,
snoozed_until,
});
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
} catch (error) {
commit(types.SET_NOTIFICATIONS_UI_FLAG, { isUpdating: false });
}
},
updateNotification: async ({ commit }, data) => {
commit(types.UPDATE_NOTIFICATION, data);
},
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data);
},
clear({ commit }) {
commit(types.CLEAR_NOTIFICATIONS);
},
setNotificationFilters: ({ commit }, filters) => {
commit(types.SET_NOTIFICATION_FILTERS, filters);
},
updateNotificationFilters: ({ commit }, filters) => {
commit(types.UPDATE_NOTIFICATION_FILTERS, filters);
},
};

View File

@@ -0,0 +1,44 @@
import { sortComparator } from './helpers';
import camelcaseKeys from 'camelcase-keys';
export const getters = {
getNotifications($state) {
return Object.values($state.records).sort((n1, n2) => n2.id - n1.id);
},
getFilteredNotifications: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return sortedNotifications;
},
getFilteredNotificationsV4: $state => filters => {
const sortOrder = filters.sortOrder === 'desc' ? 'newest' : 'oldest';
const sortedNotifications = Object.values($state.records).sort((n1, n2) =>
sortComparator(n1, n2, sortOrder)
);
return camelcaseKeys(sortedNotifications, { deep: true });
},
getNotificationById: $state => id => {
return $state.records[id] || {};
},
getUIFlags($state) {
return $state.uiFlags;
},
getNotification: $state => id => {
const notification = $state.records[id];
return notification || {};
},
getMeta: $state => {
return $state.meta;
},
getNotificationFilters($state) {
return $state.notificationFilters;
},
getHasUnreadNotifications: $state => {
return $state.meta.unreadCount > 0;
},
getUnreadCount: $state => {
return $state.meta.unreadCount;
},
};

View File

@@ -0,0 +1,17 @@
const INBOX_SORT_OPTIONS = {
newest: 'desc',
oldest: 'asc',
};
const sortConfig = {
newest: (a, b) => b.created_at - a.created_at,
oldest: (a, b) => a.created_at - b.created_at,
};
export const sortComparator = (a, b, sortOrder) => {
const sortDirection = INBOX_SORT_OPTIONS[sortOrder];
if (sortOrder === 'newest' || sortOrder === 'oldest') {
return sortConfig[sortOrder](a, b, sortDirection);
}
return 0;
};

View File

@@ -0,0 +1,29 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
meta: {
count: 0,
currentPage: 1,
unreadCount: 0,
},
records: {},
uiFlags: {
isFetching: false,
isFetchingItem: false,
isUpdating: false,
isDeleting: false,
isUpdatingUnreadCount: false,
isAllNotificationsLoaded: false,
},
notificationFilters: {},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,107 @@
import types from '../../mutation-types';
export const mutations = {
[types.SET_NOTIFICATIONS_UI_FLAG]($state, data) {
$state.uiFlags = {
...$state.uiFlags,
...data,
};
},
[types.CLEAR_NOTIFICATIONS]: $state => {
$state.records = {};
$state.uiFlags.isAllNotificationsLoaded = false;
},
[types.SET_NOTIFICATIONS_META]: ($state, data) => {
const {
count,
current_page: currentPage,
unread_count: unreadCount,
} = data;
$state.meta = { ...$state.meta, count, currentPage, unreadCount };
},
[types.SET_NOTIFICATIONS_UNREAD_COUNT]: ($state, count) => {
$state.meta.unreadCount = count < 0 ? 0 : count;
},
[types.SET_NOTIFICATIONS]: ($state, data) => {
data.forEach(notification => {
// Find existing notification with same primary_actor_id (primary_actor_id is unique)
const existingNotification = Object.values($state.records).find(
record => record.primary_actor_id === notification.primary_actor_id
);
// This is to handle the case where the same notification is received multiple times
// On reconnect, if there is existing notification with same primary_actor_id,
// it will be deleted and the new one will be added. So it will solve with duplicate notification
if (existingNotification) {
delete $state.records[existingNotification.id];
}
$state.records[notification.id] = {
...($state.records[notification.id] || {}),
...notification,
};
});
},
[types.READ_NOTIFICATION]: ($state, { id, read_at }) => {
$state.records[id].read_at = read_at;
},
[types.UPDATE_ALL_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
$state.records[item.id].read_at = true;
});
},
[types.ADD_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
$state.records[notification.id] = {
...($state.records[notification.id] || {}),
...notification,
};
$state.meta.unreadCount = unreadCount;
$state.meta.count = count;
},
[types.UPDATE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
$state.records[notification.id] = {
...($state.records[notification.id] || {}),
...notification,
};
$state.meta.unreadCount = unreadCount;
$state.meta.count = count;
},
[types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
delete $state.records[notification.id];
$state.meta.unreadCount = unreadCount;
$state.meta.count = count;
},
[types.SET_ALL_NOTIFICATIONS_LOADED]: $state => {
$state.uiFlags.isAllNotificationsLoaded = true;
},
[types.DELETE_READ_NOTIFICATIONS]: $state => {
Object.values($state.records).forEach(item => {
if (item.read_at) {
delete $state.records[item.id];
}
});
},
[types.DELETE_ALL_NOTIFICATIONS]: $state => {
$state.records = {};
},
[types.SNOOZE_NOTIFICATION]: ($state, { id, snoozed_until }) => {
$state.records[id].snoozed_until = snoozed_until;
},
[types.SET_NOTIFICATION_FILTERS]: ($state, filters) => {
$state.notificationFilters = filters;
},
[types.UPDATE_NOTIFICATION_FILTERS]: ($state, filters) => {
$state.notificationFilters = {
...$state.notificationFilters,
...filters,
};
},
};

View File

@@ -0,0 +1,367 @@
/* 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 { 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,
incoming_messages_count: false,
outgoing_messages_count: false,
avg_first_response_time: false,
avg_resolution_time: false,
resolutions_count: false,
bot_resolutions_count: false,
bot_handoffs_count: false,
reply_time: false,
},
data: {
conversations_count: [],
incoming_messages_count: [],
outgoing_messages_count: [],
avg_first_response_time: [],
avg_resolution_time: [],
resolutions_count: [],
bot_resolutions_count: [],
bot_handoffs_count: [],
reply_time: [],
},
},
accountSummary: {
avg_first_response_time: 0,
avg_resolution_time: 0,
conversations_count: 0,
incoming_messages_count: 0,
outgoing_messages_count: 0,
reply_time: 0,
resolutions_count: 0,
bot_resolutions_count: 0,
bot_handoffs_count: 0,
previous: {},
},
botSummary: {
bot_resolutions_count: 0,
bot_handoffs_count: 0,
previous: {},
},
overview: {
uiFlags: {
isFetchingAccountConversationMetric: false,
isFetchingAccountConversationsHeatmap: false,
isFetchingAccountResolutionsHeatmap: false,
isFetchingAgentConversationMetric: false,
isFetchingTeamConversationMetric: false,
},
accountConversationMetric: {},
accountConversationHeatmap: [],
accountResolutionHeatmap: [],
agentConversationMetric: [],
teamConversationMetric: [],
},
};
const getters = {
getAccountReports(_state) {
return _state.accountReport;
},
getAccountSummary(_state) {
return _state.accountSummary;
},
getBotSummary(_state) {
return _state.botSummary;
},
getAccountSummaryFetchingStatus(_state) {
return _state.accountSummaryFetchingStatus;
},
getBotSummaryFetchingStatus(_state) {
return _state.botSummaryFetchingStatus;
},
getAccountConversationMetric(_state) {
return _state.overview.accountConversationMetric;
},
getAccountConversationHeatmapData(_state) {
return _state.overview.accountConversationHeatmap;
},
getAccountResolutionHeatmapData(_state) {
return _state.overview.accountResolutionHeatmap;
},
getAgentConversationMetric(_state) {
return _state.overview.agentConversationMetric;
},
getTeamConversationMetric(_state) {
return _state.overview.teamConversationMetric;
},
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
};
export const actions = {
fetchAccountReport({ commit }, reportObj) {
const { metric } = reportObj;
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, {
metric,
value: true,
});
Report.getReports(reportObj).then(accountReport => {
let { data } = accountReport;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_ACCOUNT_REPORTS, {
metric,
data,
});
commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, {
metric,
value: false,
});
});
},
fetchAccountConversationHeatmap({ commit }, reportObj) {
commit(types.default.TOGGLE_HEATMAP_LOADING, true);
Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_HEATMAP_DATA, data);
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
});
},
fetchAccountResolutionHeatmap({ commit }, reportObj) {
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, true);
Report.getReports({ ...reportObj, groupBy: 'hour' }).then(heatmapData => {
let { data } = heatmapData;
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
commit(types.default.SET_RESOLUTION_HEATMAP_DATA, data);
commit(types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING, false);
});
},
fetchAccountSummary({ commit }, reportObj) {
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FETCHING);
Report.getSummary(
reportObj.from,
reportObj.to,
reportObj.type,
reportObj.id,
reportObj.groupBy,
reportObj.businessHours
)
.then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);
commit(types.default.SET_ACCOUNT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
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,
groupBy: reportObj.groupBy,
businessHours: reportObj.businessHours,
})
.then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FINISHED);
})
.catch(() => {
commit(types.default.SET_BOT_SUMMARY_STATUS, STATUS.FAILED);
});
},
fetchAccountConversationMetric({ commit }, params = {}) {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, true);
liveReports
.getConversationMetric(params)
.then(accountConversationMetric => {
commit(
types.default.SET_ACCOUNT_CONVERSATION_METRIC,
accountConversationMetric.data
);
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
commit(types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING, false);
});
},
fetchAgentConversationMetric({ commit }) {
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, true);
liveReports
.getGroupedConversations({ groupBy: 'assignee_id' })
.then(agentConversationMetric => {
commit(
types.default.SET_AGENT_CONVERSATION_METRIC,
agentConversationMetric.data
);
commit(types.default.TOGGLE_AGENT_CONVERSATION_METRIC_LOADING, false);
})
.catch(() => {
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 => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'agent',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadConversationsSummaryReports(_, reportObj) {
return Report.getConversationsSummaryReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'conversations_summary',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadLabelReports(_, reportObj) {
return Report.getLabelReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'label',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadInboxReports(_, reportObj) {
return Report.getInboxReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'inbox',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadTeamReports(_, reportObj) {
return Report.getTeamReports(reportObj)
.then(response => {
downloadCsvFile(reportObj.fileName, response.data);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'team',
businessHours: reportObj?.businessHours,
});
})
.catch(error => {
console.error(error);
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
downloadCsvFile(
generateFileName({
type: 'Conversation traffic',
to: reportObj.to,
}),
response.data
);
AnalyticsHelper.track(REPORTS_EVENTS.DOWNLOAD_REPORT, {
reportType: 'conversation_heatmap',
businessHours: false,
});
})
.catch(error => {
console.error(error);
});
},
};
const mutations = {
[types.default.SET_ACCOUNT_REPORTS](_state, { metric, data }) {
_state.accountReport.data[metric] = data;
},
[types.default.SET_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountConversationHeatmap = heatmapData;
},
[types.default.SET_RESOLUTION_HEATMAP_DATA](_state, heatmapData) {
_state.overview.accountResolutionHeatmap = heatmapData;
},
[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;
},
[types.default.TOGGLE_RESOLUTION_HEATMAP_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountResolutionsHeatmap = flag;
},
[types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) {
_state.accountSummary = summaryData;
},
[types.default.SET_BOT_SUMMARY](_state, summaryData) {
_state.botSummary = summaryData;
},
[types.default.SET_ACCOUNT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.accountConversationMetric = metricData;
},
[types.default.TOGGLE_ACCOUNT_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingAccountConversationMetric = flag;
},
[types.default.SET_AGENT_CONVERSATION_METRIC](_state, metricData) {
_state.overview.agentConversationMetric = metricData;
},
[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 {
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,86 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import types from '../mutation-types';
import SlaAPI from '../../api/sla';
import AnalyticsHelper from '../../helper/AnalyticsHelper';
import { SLA_EVENTS } from '../../helper/AnalyticsHelper/events';
import { throwErrorMessage } from '../utils/api';
export const state = {
records: [],
uiFlags: {
isFetching: false,
isFetchingItem: false,
isCreating: false,
isDeleting: false,
},
};
export const getters = {
getSLA(_state) {
return _state.records;
},
getUIFlags(_state) {
return _state.uiFlags;
},
};
export const actions = {
get: async function get({ commit }) {
commit(types.SET_SLA_UI_FLAG, { isFetching: true });
try {
const response = await SlaAPI.get();
commit(types.SET_SLA, response.data.payload);
} catch (error) {
// Ignore error
} finally {
commit(types.SET_SLA_UI_FLAG, { isFetching: false });
}
},
create: async function create({ commit }, slaObj) {
commit(types.SET_SLA_UI_FLAG, { isCreating: true });
try {
const response = await SlaAPI.create(slaObj);
AnalyticsHelper.track(SLA_EVENTS.CREATE);
commit(types.ADD_SLA, response.data.payload);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_SLA_UI_FLAG, { isCreating: false });
}
},
delete: async function deleteSla({ commit }, id) {
commit(types.SET_SLA_UI_FLAG, { isDeleting: true });
try {
await SlaAPI.delete(id);
AnalyticsHelper.track(SLA_EVENTS.DELETED);
commit(types.DELETE_SLA, id);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_SLA_UI_FLAG, { isDeleting: false });
}
},
};
export const mutations = {
[types.SET_SLA_UI_FLAG](_state, data) {
_state.uiFlags = {
..._state.uiFlags,
...data,
};
},
[types.SET_SLA]: MutationHelpers.set,
[types.ADD_SLA]: MutationHelpers.create,
[types.DELETE_SLA]: MutationHelpers.destroy,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,123 @@
import axios from 'axios';
import { actions, getters } from '../../accounts';
import * as types from '../../../mutation-types';
const accountData = {
id: 1,
name: 'Company one',
locale: 'en',
};
const newAccountInfo = {
accountName: 'Company two',
};
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: accountData });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true }],
[types.default.ADD_ACCOUNT, accountData],
[types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isFetchingItem: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
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 }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.update({ commit, getters }, accountData)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({
data: { data: { id: 1, name: 'John' } },
});
await actions.create({ commit, getters }, newAccountInfo);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isCreating: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.patch.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.create({ commit, getters }, newAccountInfo)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.default.SET_ACCOUNT_UI_FLAG, { isCreating: true }],
[types.default.SET_ACCOUNT_UI_FLAG, { isCreating: false }],
]);
});
});
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 }],
]);
});
});
});

View File

@@ -0,0 +1,122 @@
import { getters } from '../../accounts';
import * as languageHelpers from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
const accountData = {
id: 1,
name: 'Company one',
locale: 'en',
features: {
auto_resolve_conversations: true,
agent_management: false,
},
};
describe('#getters', () => {
it('getAccount', () => {
const state = {
records: [accountData],
};
expect(getters.getAccount(state)(1)).toEqual(accountData);
});
it('getUIFlags', () => {
const state = {
uiFlags: {
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isFetching: true,
isCreating: false,
isUpdating: false,
isDeleting: false,
});
});
it('isFeatureEnabledonAccount', () => {
const state = {
records: [accountData],
};
expect(
getters.isFeatureEnabledonAccount(
state,
null,
null
)(1, 'auto_resolve_conversations')
).toEqual(true);
});
describe('isRTL', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns false when accountId is not present and userLocale is not set', () => {
const state = { records: [accountData] };
const rootState = { route: { params: {} } };
const rootGetters = {};
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
});
it('uses userLocale when present (no accountId)', () => {
const state = { records: [accountData] };
const rootState = { route: { params: {} } };
const rootGetters = { getUISettings: { locale: 'ar' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('prefers userLocale over account locale when both are present', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = { getUISettings: { locale: 'ar' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('falls back to account locale when userLocale is not provided', () => {
const state = { records: [{ id: 1, locale: 'ar' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = {};
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(true);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(true);
expect(spy).toHaveBeenCalledWith('ar');
});
it('returns false for LTR language when userLocale is provided', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = { getUISettings: { locale: 'en' } };
const spy = vi
.spyOn(languageHelpers, 'getLanguageDirection')
.mockReturnValue(false);
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
expect(spy).toHaveBeenCalledWith('en');
});
it('returns false when accountId present but user locale is null', () => {
const state = { records: [{ id: 1, locale: 'en' }] };
const rootState = { route: { params: { accountId: '1' } } };
const rootGetters = { getUISettings: { locale: null } };
const spy = vi.spyOn(languageHelpers, 'getLanguageDirection');
expect(getters.isRTL(state, null, rootState, rootGetters)).toBe(false);
expect(spy).toHaveBeenCalledWith('en');
});
});
});

View File

@@ -0,0 +1,30 @@
import * as types from '../../../mutation-types';
import { mutations } from '../../accounts';
const accountData = {
id: 1,
name: 'Company one',
locale: 'en',
};
describe('#mutations', () => {
describe('#ADD_ACCOUNT', () => {
it('push contact data to the store', () => {
const state = {
records: [],
};
mutations[types.default.ADD_ACCOUNT](state, accountData);
expect(state.records).toEqual([accountData]);
});
});
describe('#EDIT_ACCOUNT', () => {
it('update contact', () => {
const state = {
records: [{ ...accountData, locale: 'fr' }],
};
mutations[types.default.EDIT_ACCOUNT](state, accountData);
expect(state.records).toEqual([accountData]);
});
});
});

View File

@@ -0,0 +1,190 @@
import axios from 'axios';
import { actions } from '../../agentBots';
import types from '../../../mutation-types';
import { agentBotRecords, agentBotData } from './fixtures';
const commit = vi.fn();
global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
describe('#get', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: agentBotRecords });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isFetching: true }],
[types.SET_AGENT_BOTS, agentBotRecords],
[types.SET_AGENT_BOT_UI_FLAG, { isFetching: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await actions.get({ commit });
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isFetching: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isFetching: false }],
]);
});
});
describe('#create', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: 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);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isCreating: false }],
]);
});
});
describe('#update', () => {
it('sends correct actions if API is success', async () => {
axios.patch.mockResolvedValue({ data: 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 }, { id: 1, data: {} })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isUpdating: false }],
]);
});
});
describe('#delete', () => {
it('sends correct actions if API is success', async () => {
axios.delete.mockResolvedValue({ data: agentBotRecords[0] });
await actions.delete({ commit }, agentBotRecords[0].id);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true }],
[types.DELETE_AGENT_BOT, agentBotRecords[0].id],
[types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.delete.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.delete({ commit }, agentBotRecords[0].id)
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isDeleting: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isDeleting: false }],
]);
});
});
describe('#setAgentBotInbox', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: {} });
await actions.setAgentBotInbox({ commit }, { inboxId: 2, botId: 3 });
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: true }],
[types.SET_AGENT_BOT_INBOX, { inboxId: 2, agentBotId: 3 }],
[types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.setAgentBotInbox({ commit }, { inboxId: 2, botId: 3 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isSettingAgentBot: false }],
]);
});
});
describe('#fetchAgentBotInbox', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({ data: { agent_bot: { id: 3 } } });
await actions.fetchAgentBotInbox({ commit }, 2);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: true }],
[types.SET_AGENT_BOT_INBOX, { inboxId: 2, agentBotId: 3 }],
[types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.fetchAgentBotInbox({ commit }, { inboxId: 2, agentBotId: 3 })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isFetchingAgentBot: false }],
]);
});
});
describe('#disconnectBot', () => {
it('sends correct actions if API is success', async () => {
axios.post.mockResolvedValue({ data: {} });
await actions.disconnectBot({ commit }, { inboxId: 2 });
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: true }],
[types.SET_AGENT_BOT_INBOX, { inboxId: 2, agentBotId: '' }],
[types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false }],
]);
});
it('sends correct actions if API is error', async () => {
axios.post.mockRejectedValue({ message: 'Incorrect header' });
await expect(
actions.disconnectBot({ commit }, { inboxId: 2, agentBotId: '' })
).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: true }],
[types.SET_AGENT_BOT_UI_FLAG, { isDisconnecting: false }],
]);
});
});
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);
});
});
});

View File

@@ -0,0 +1,35 @@
export const agentBotRecords = [
{
account_id: 1,
id: 11,
name: 'Agent Bot 11',
description: 'Agent Bot Description',
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',
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'),
};

Some files were not shown because too many files have changed in this diff Show More