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,29 @@
import { createStore } from 'vuex';
import agent from 'widget/store/modules/agent';
import appConfig from 'widget/store/modules/appConfig';
import contacts from 'widget/store/modules/contacts';
import conversation from 'widget/store/modules/conversation';
import conversationAttributes from 'widget/store/modules/conversationAttributes';
import conversationLabels from 'widget/store/modules/conversationLabels';
import events from 'widget/store/modules/events';
import globalConfig from 'shared/store/globalConfig';
import message from 'widget/store/modules/message';
import campaign from 'widget/store/modules/campaign';
import article from 'widget/store/modules/articles';
export default createStore({
modules: {
agent,
appConfig,
contacts,
conversation,
conversationAttributes,
conversationLabels,
events,
globalConfig,
message,
campaign,
article,
},
});

View File

@@ -0,0 +1,67 @@
import { getAvailableAgents } from 'widget/api/agent';
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import { getFromCache, setCache } from 'shared/helpers/cache';
const state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
},
};
export const getters = {
getHasFetched: $state => $state.uiFlags.hasFetched,
availableAgents: $state =>
$state.records.filter(agent => agent.availability_status === 'online'),
};
const CACHE_KEY_PREFIX = 'chatwoot_available_agents_';
export const actions = {
fetchAvailableAgents: async ({ commit }, websiteToken) => {
try {
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${websiteToken}`);
if (cachedData) {
commit('setAgents', cachedData);
commit('setError', false);
commit('setHasFetched', true);
return;
}
const { data } = await getAvailableAgents(websiteToken);
const { payload = [] } = data;
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, payload);
commit('setAgents', payload);
commit('setError', false);
commit('setHasFetched', true);
} catch (error) {
commit('setError', true);
commit('setHasFetched', true);
}
},
updatePresence: async ({ commit }, data) => {
commit('updatePresence', data);
},
};
export const mutations = {
setAgents($state, data) {
$state.records = data;
},
updatePresence: MutationHelpers.updatePresence,
setError($state, value) {
$state.uiFlags.isError = value;
},
setHasFetched($state, value) {
$state.uiFlags.hasFetched = value;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,155 @@
import {
SET_BUBBLE_VISIBILITY,
SET_COLOR_SCHEME,
SET_REFERRER_HOST,
SET_WIDGET_APP_CONFIG,
SET_WIDGET_COLOR,
TOGGLE_WIDGET_OPEN,
SET_ROUTE_UPDATE_STATE,
} from '../types';
const state = {
hideMessageBubble: false,
isCampaignViewClicked: false,
showUnreadMessagesDialog: true,
isWebWidgetTriggered: false,
isWidgetOpen: false,
position: 'right',
referrerHost: '',
showPopoutButton: false,
widgetColor: '',
widgetStyle: 'standard',
darkMode: 'light',
isUpdatingRoute: false,
welcomeTitle: '',
welcomeDescription: '',
availableMessage: '',
unavailableMessage: '',
enableFileUpload: undefined,
enableEmojiPicker: true,
enableEndConversation: true,
};
export const getters = {
getAppConfig: $state => $state,
isRightAligned: $state => $state.position === 'right',
getHideMessageBubble: $state => $state.hideMessageBubble,
getIsWidgetOpen: $state => $state.isWidgetOpen,
getWidgetColor: $state => $state.widgetColor,
getReferrerHost: $state => $state.referrerHost,
isWidgetStyleFlat: $state => $state.widgetStyle === 'flat',
darkMode: $state => $state.darkMode,
getShowUnreadMessagesDialog: $state => $state.showUnreadMessagesDialog,
getIsUpdatingRoute: _state => _state.isUpdatingRoute,
getWelcomeHeading: $state => $state.welcomeTitle,
getWelcomeTagline: $state => $state.welcomeDescription,
getAvailableMessage: $state => $state.availableMessage,
getUnavailableMessage: $state => $state.unavailableMessage,
getShouldShowFilePicker: $state => $state.enableFileUpload,
getShouldShowEmojiPicker: $state => $state.enableEmojiPicker,
getCanUserEndConversation: $state => $state.enableEndConversation,
};
export const actions = {
setAppConfig(
{ commit },
{
showPopoutButton,
position,
hideMessageBubble,
showUnreadMessagesDialog,
widgetStyle = 'rounded',
darkMode = 'light',
welcomeTitle = '',
welcomeDescription = '',
availableMessage = '',
unavailableMessage = '',
enableFileUpload = undefined,
enableEmojiPicker = true,
enableEndConversation = true,
}
) {
commit(SET_WIDGET_APP_CONFIG, {
hideMessageBubble: !!hideMessageBubble,
position: position || 'right',
showPopoutButton: !!showPopoutButton,
showUnreadMessagesDialog: !!showUnreadMessagesDialog,
widgetStyle,
darkMode,
welcomeTitle,
welcomeDescription,
availableMessage,
unavailableMessage,
enableFileUpload,
enableEmojiPicker,
enableEndConversation,
});
},
toggleWidgetOpen({ commit }, isWidgetOpen) {
commit(TOGGLE_WIDGET_OPEN, isWidgetOpen);
},
setWidgetColor({ commit }, widgetColor) {
commit(SET_WIDGET_COLOR, widgetColor);
},
setColorScheme({ commit }, darkMode) {
commit(SET_COLOR_SCHEME, darkMode);
},
setReferrerHost({ commit }, referrerHost) {
commit(SET_REFERRER_HOST, referrerHost);
},
setBubbleVisibility({ commit }, hideMessageBubble) {
commit(SET_BUBBLE_VISIBILITY, hideMessageBubble);
},
setRouteTransitionState: async ({ commit }, status) => {
// Handles the routing state during navigation to different screen
// Called before the navigation starts and after navigation completes
// Handling this state in app/javascript/widget/router.js
// See issue: https://github.com/chatwoot/chatwoot/issues/10736
commit(SET_ROUTE_UPDATE_STATE, status);
},
};
export const mutations = {
[SET_WIDGET_APP_CONFIG]($state, data) {
$state.showPopoutButton = data.showPopoutButton;
$state.position = data.position;
$state.hideMessageBubble = data.hideMessageBubble;
$state.widgetStyle = data.widgetStyle;
$state.darkMode = data.darkMode;
$state.locale = data.locale || $state.locale;
$state.showUnreadMessagesDialog = data.showUnreadMessagesDialog;
$state.welcomeTitle = data.welcomeTitle;
$state.welcomeDescription = data.welcomeDescription;
$state.availableMessage = data.availableMessage;
$state.unavailableMessage = data.unavailableMessage;
$state.enableFileUpload = data.enableFileUpload;
$state.enableEmojiPicker = data.enableEmojiPicker;
$state.enableEndConversation = data.enableEndConversation;
},
[TOGGLE_WIDGET_OPEN]($state, isWidgetOpen) {
$state.isWidgetOpen = isWidgetOpen;
},
[SET_WIDGET_COLOR]($state, widgetColor) {
$state.widgetColor = widgetColor;
},
[SET_REFERRER_HOST]($state, referrerHost) {
$state.referrerHost = referrerHost;
},
[SET_BUBBLE_VISIBILITY]($state, hideMessageBubble) {
$state.hideMessageBubble = hideMessageBubble;
},
[SET_COLOR_SCHEME]($state, darkMode) {
$state.darkMode = darkMode;
},
[SET_ROUTE_UPDATE_STATE]($state, status) {
$state.isUpdatingRoute = status;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,66 @@
import { getMostReadArticles } from 'widget/api/article';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_most_read_articles_';
const state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
isFetching: false,
},
};
export const getters = {
uiFlags: $state => $state.uiFlags,
popularArticles: $state => $state.records,
};
export const actions = {
fetch: async ({ commit }, { slug, locale }) => {
commit('setIsFetching', true);
commit('setError', false);
try {
if (!locale) return;
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
if (cachedData) {
commit('setArticles', cachedData);
return;
}
const { data } = await getMostReadArticles(slug, locale);
const { payload = [] } = data;
setCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`, payload);
if (payload.length) {
commit('setArticles', payload);
}
} catch (error) {
commit('setError', true);
} finally {
commit('setIsFetching', false);
}
},
};
export const mutations = {
setArticles($state, data) {
$state.records = data;
},
setError($state, value) {
$state.uiFlags.isError = value;
},
setIsFetching($state, value) {
$state.uiFlags.isFetching = value;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,183 @@
import { getCampaigns, triggerCampaign } from 'widget/api/campaign';
import campaignTimer from 'widget/helpers/campaignTimer';
import {
formatCampaigns,
filterCampaigns,
} from 'widget/helpers/campaignHelper';
import { getFromCache, setCache } from 'shared/helpers/cache';
const CACHE_KEY_PREFIX = 'chatwoot_campaigns_';
const state = {
records: [],
uiFlags: {
hasFetched: false,
isError: false,
},
activeCampaign: {},
};
const resetCampaignTimers = (
campaigns,
currentURL,
websiteToken,
isInBusinessHours
) => {
const formattedCampaigns = formatCampaigns({ campaigns });
// Find all campaigns that matches the current URL
const filteredCampaigns = filterCampaigns({
campaigns: formattedCampaigns,
currentURL,
isInBusinessHours,
});
campaignTimer.initTimers({ campaigns: filteredCampaigns }, websiteToken);
};
export const getters = {
getCampaigns: $state => $state.records,
getUIFlags: $state => $state.uiFlags,
getActiveCampaign: $state => $state.activeCampaign,
};
export const actions = {
fetchCampaigns: async (
{ commit },
{ websiteToken, currentURL, isInBusinessHours }
) => {
try {
// Cache for 1 hour
const CACHE_EXPIRY = 60 * 60 * 1000;
const cachedData = getFromCache(
`${CACHE_KEY_PREFIX}${websiteToken}`,
CACHE_EXPIRY
);
if (cachedData) {
commit('setCampaigns', cachedData);
commit('setError', false);
resetCampaignTimers(
cachedData,
currentURL,
websiteToken,
isInBusinessHours
);
return;
}
const { data: campaigns } = await getCampaigns(websiteToken);
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, campaigns);
commit('setCampaigns', campaigns);
commit('setError', false);
resetCampaignTimers(
campaigns,
currentURL,
websiteToken,
isInBusinessHours
);
} catch (error) {
commit('setError', true);
}
},
initCampaigns: async (
{ getters: { getCampaigns: campaigns, getUIFlags: uiFlags }, dispatch },
{ currentURL, websiteToken, isInBusinessHours }
) => {
if (!campaigns.length) {
// This check is added to ensure that the campaigns are fetched once
// On high traffic sites, if the campaigns are empty, the API is called
// every time the user changes the URL (in case of the SPA)
// So, we need to ensure that the campaigns are fetched only once
if (!uiFlags.hasFetched) {
dispatch('fetchCampaigns', {
websiteToken,
currentURL,
isInBusinessHours,
});
}
} else {
resetCampaignTimers(
campaigns,
currentURL,
websiteToken,
isInBusinessHours
);
}
},
startCampaign: async (
{
commit,
rootState: {
appConfig: { isWidgetOpen },
},
},
{ websiteToken, campaignId }
) => {
// Disable campaign execution if widget is opened
if (!isWidgetOpen) {
const { data: campaigns } = await getCampaigns(websiteToken);
// Check campaign is disabled or not
const campaign = campaigns.find(item => item.id === campaignId);
if (campaign) {
commit('setActiveCampaign', campaign);
}
}
},
executeCampaign: async (
{ commit },
{ campaignId, websiteToken, customAttributes }
) => {
try {
commit(
'conversation/setConversationUIFlag',
{ isCreating: true },
{ root: true }
);
await triggerCampaign({ campaignId, websiteToken, customAttributes });
commit('setCampaignExecuted', true);
commit('setActiveCampaign', {});
} catch (error) {
commit('setError', true);
} finally {
commit(
'conversation/setConversationUIFlag',
{ isCreating: false },
{ root: true }
);
}
},
resetCampaign: async ({ commit }) => {
try {
commit('setCampaignExecuted', false);
commit('setActiveCampaign', {});
} catch (error) {
commit('setError', true);
}
},
};
export const mutations = {
setCampaigns($state, data) {
$state.records = data;
$state.uiFlags.hasFetched = true;
},
setActiveCampaign($state, data) {
$state.activeCampaign = data;
},
setError($state, value) {
$state.uiFlags.isError = value;
},
setHasFetched($state, value) {
$state.uiFlags.hasFetched = value;
},
setCampaignExecuted($state, data) {
$state.campaignHasExecuted = data;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,119 @@
import { sendMessage } from 'widget/helpers/utils';
import ContactsAPI from '../../api/contacts';
import { SET_USER_ERROR } from '../../constants/errorTypes';
import { setHeader } from '../../helpers/axios';
const state = {
currentUser: {},
};
const SET_CURRENT_USER = 'SET_CURRENT_USER';
const parseErrorData = error =>
error && error.response && error.response.data ? error.response.data : error;
export const updateWidgetAuthToken = widgetAuthToken => {
if (widgetAuthToken) {
setHeader(widgetAuthToken);
sendMessage({
event: 'setAuthCookie',
data: { widgetAuthToken },
});
}
};
export const getters = {
getCurrentUser(_state) {
return _state.currentUser;
},
};
export const actions = {
get: async ({ commit }) => {
try {
const { data } = await ContactsAPI.get();
commit(SET_CURRENT_USER, data);
} catch (error) {
// Ignore error
}
},
update: async ({ dispatch }, { user }) => {
try {
await ContactsAPI.update(user);
dispatch('get');
} catch (error) {
// Ignore error
}
},
setUser: async ({ dispatch }, { identifier, user: userObject }) => {
try {
const {
email,
name,
avatar_url,
identifier_hash: identifierHash,
phone_number,
company_name,
city,
country_code,
description,
custom_attributes,
social_profiles,
} = userObject;
const user = {
email,
name,
avatar_url,
identifier_hash: identifierHash,
phone_number,
additional_attributes: {
company_name,
city,
description,
country_code,
social_profiles,
},
custom_attributes,
};
const {
data: { widget_auth_token: widgetAuthToken },
} = await ContactsAPI.setUser(identifier, user);
updateWidgetAuthToken(widgetAuthToken);
dispatch('get');
if (identifierHash || widgetAuthToken) {
dispatch('conversation/clearConversations', {}, { root: true });
dispatch('conversation/fetchOldConversations', {}, { root: true });
dispatch('conversationAttributes/getAttributes', {}, { root: true });
}
} catch (error) {
const data = parseErrorData(error);
sendMessage({ event: 'error', errorType: SET_USER_ERROR, data });
}
},
setCustomAttributes: async (_, customAttributes = {}) => {
try {
await ContactsAPI.setCustomAttributes(customAttributes);
} catch (error) {
// Ignore error
}
},
deleteCustomAttribute: async (_, customAttribute) => {
try {
await ContactsAPI.deleteCustomAttribute(customAttribute);
} catch (error) {
// Ignore error
}
},
};
export const mutations = {
[SET_CURRENT_USER]($state, user) {
const { currentUser } = $state;
$state.currentUser = { ...currentUser, ...user };
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,198 @@
import {
createConversationAPI,
sendMessageAPI,
getMessagesAPI,
sendAttachmentAPI,
toggleTyping,
setUserLastSeenAt,
toggleStatus,
setCustomAttributes,
deleteCustomAttribute,
} from 'widget/api/conversation';
import { ON_CONVERSATION_CREATED } from 'widget/constants/widgetBusEvents';
import { createTemporaryMessage, getNonDeletedMessages } from './helpers';
import { emitter } from 'shared/helpers/mitt';
export const actions = {
createConversation: async ({ commit, dispatch }, params) => {
commit('setConversationUIFlag', { isCreating: true });
try {
const { data } = await createConversationAPI(params);
const { messages } = data;
const [message = {}] = messages;
commit('pushMessageToConversation', message);
dispatch('conversationAttributes/getAttributes', {}, { root: true });
// Emit event to notify that conversation is created and show the chat screen
emitter.emit(ON_CONVERSATION_CREATED);
} catch (error) {
// Ignore error
} finally {
commit('setConversationUIFlag', { isCreating: false });
}
},
sendMessage: async ({ dispatch }, params) => {
const { content, replyTo } = params;
const message = createTemporaryMessage({ content, replyTo });
dispatch('sendMessageWithData', message);
},
sendMessageWithData: async ({ commit }, message) => {
const { id, content, replyTo, meta = {} } = message;
commit('pushMessageToConversation', message);
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
try {
const { data } = await sendMessageAPI(content, replyTo);
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
// commit('deleteMessage', message.id);
commit('pushMessageToConversation', { ...data, status: 'sent' });
} catch (error) {
commit('pushMessageToConversation', { ...message, status: 'failed' });
commit('updateMessageMeta', {
id,
meta: { ...meta, error: '' },
});
}
},
setLastMessageId: async ({ commit }) => {
commit('setLastMessageId');
},
sendAttachment: async ({ commit }, params) => {
const {
attachment: { thumbUrl, fileType },
meta = {},
} = params;
const attachment = {
thumb_url: thumbUrl,
data_url: thumbUrl,
file_type: fileType,
status: 'in_progress',
};
const tempMessage = createTemporaryMessage({
attachments: [attachment],
replyTo: params.replyTo,
});
commit('pushMessageToConversation', tempMessage);
try {
const { data } = await sendAttachmentAPI(params);
commit('updateAttachmentMessageStatus', {
message: data,
tempId: tempMessage.id,
});
commit('pushMessageToConversation', { ...data, status: 'sent' });
} catch (error) {
commit('pushMessageToConversation', { ...tempMessage, status: 'failed' });
commit('updateMessageMeta', {
id: tempMessage.id,
meta: { ...meta, error: '' },
});
// Show error
}
},
fetchOldConversations: async ({ commit }, { before } = {}) => {
try {
commit('setConversationListLoading', true);
const {
data: { payload, meta },
} = await getMessagesAPI({ before });
const { contact_last_seen_at: lastSeen } = meta;
const formattedMessages = getNonDeletedMessages({ messages: payload });
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
commit('setMessagesInConversation', formattedMessages);
commit('setConversationListLoading', false);
} catch (error) {
commit('setConversationListLoading', false);
}
},
syncLatestMessages: async ({ state, commit }) => {
try {
const { lastMessageId, conversations } = state;
const {
data: { payload, meta },
} = await getMessagesAPI({ after: lastMessageId });
const { contact_last_seen_at: lastSeen } = meta;
const formattedMessages = getNonDeletedMessages({ messages: payload });
const missingMessages = formattedMessages.filter(
message => conversations?.[message.id] === undefined
);
if (!missingMessages.length) return;
missingMessages.forEach(message => {
conversations[message.id] = message;
});
// Sort conversation messages by created_at
const updatedConversation = Object.fromEntries(
Object.entries(conversations).sort(
(a, b) => a[1].created_at - b[1].created_at
)
);
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
commit('setMissingMessagesInConversation', updatedConversation);
} catch (error) {
// IgnoreError
}
},
clearConversations: ({ commit }) => {
commit('clearConversations');
},
addOrUpdateMessage: async ({ commit }, data) => {
const { id, content_attributes } = data;
if (content_attributes && content_attributes.deleted) {
commit('deleteMessage', id);
return;
}
commit('pushMessageToConversation', data);
},
toggleAgentTyping({ commit }, data) {
commit('toggleAgentTypingStatus', data);
},
toggleUserTyping: async (_, data) => {
try {
await toggleTyping(data);
} catch (error) {
// IgnoreError
}
},
setUserLastSeen: async ({ commit, getters: appGetters }) => {
if (!appGetters.getConversationSize) {
return;
}
const lastSeen = Date.now() / 1000;
try {
commit('setMetaUserLastSeenAt', lastSeen);
await setUserLastSeenAt({ lastSeen });
} catch (error) {
// IgnoreError
}
},
resolveConversation: async () => {
await toggleStatus();
},
setCustomAttributes: async (_, customAttributes = {}) => {
try {
await setCustomAttributes(customAttributes);
} catch (error) {
// IgnoreError
}
},
deleteCustomAttribute: async (_, customAttribute) => {
try {
await deleteCustomAttribute(customAttribute);
} catch (error) {
// IgnoreError
}
},
};

View File

@@ -0,0 +1,61 @@
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { groupBy } from 'widget/helpers/utils';
import { groupConversationBySender } from './helpers';
import { formatUnixDate } from 'shared/helpers/DateHelper';
export const getters = {
getAllMessagesLoaded: _state => _state.uiFlags.allMessagesLoaded,
getIsCreating: _state => _state.uiFlags.isCreating,
getIsAgentTyping: _state => _state.uiFlags.isAgentTyping,
getConversation: _state => _state.conversations,
getConversationSize: _state => Object.keys(_state.conversations).length,
getEarliestMessage: _state => {
const conversation = Object.values(_state.conversations);
if (conversation.length) {
return conversation[0];
}
return {};
},
getLastMessage: _state => {
const conversation = Object.values(_state.conversations);
if (conversation.length) {
return conversation[conversation.length - 1];
}
return {};
},
getGroupedConversation: _state => {
const conversationGroupedByDate = groupBy(
Object.values(_state.conversations),
message => formatUnixDate(message.created_at)
);
return Object.keys(conversationGroupedByDate).map(date => ({
date,
messages: groupConversationBySender(conversationGroupedByDate[date]),
}));
},
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
getMessageCount: _state => {
return Object.values(_state.conversations).length;
},
getUnreadMessageCount: _state => {
const { userLastSeenAt } = _state.meta;
return Object.values(_state.conversations).filter(chat => {
const { created_at: createdAt, message_type: messageType } = chat;
const isOutGoing = messageType === MESSAGE_TYPE.OUTGOING;
const hasNotSeen = userLastSeenAt
? createdAt * 1000 > userLastSeenAt * 1000
: true;
return hasNotSeen && isOutGoing;
}).length;
},
getUnreadTextMessages: (_state, _getters) => {
const unreadCount = _getters.getUnreadMessageCount;
const allMessages = [...Object.values(_state.conversations)];
const unreadAgentMessages = allMessages.filter(message => {
const { message_type: messageType } = message;
return messageType === MESSAGE_TYPE.OUTGOING;
});
const maxUnreadCount = Math.min(unreadCount, 3);
return unreadAgentMessages.splice(-maxUnreadCount);
},
};

View File

@@ -0,0 +1,55 @@
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import getUuid from '../../../helpers/uuid';
export const createTemporaryMessage = ({ attachments, content, replyTo }) => {
const timestamp = new Date().getTime() / 1000;
return {
id: getUuid(),
content,
attachments,
status: 'in_progress',
replyTo,
created_at: timestamp,
message_type: MESSAGE_TYPE.INCOMING,
};
};
const getSenderName = message => (message.sender ? message.sender.name : '');
const shouldShowAvatar = (message, nextMessage) => {
const currentSender = getSenderName(message);
const nextSender = getSenderName(nextMessage);
return (
currentSender !== nextSender ||
message.message_type !== nextMessage.message_type ||
isASubmittedFormMessage(nextMessage)
);
};
export const groupConversationBySender = conversationsForADate =>
conversationsForADate.map((message, index) => {
let showAvatar;
const isLastMessage = index === conversationsForADate.length - 1;
if (isASubmittedFormMessage(message)) {
showAvatar = false;
} else if (isLastMessage) {
showAvatar = true;
} else {
const nextMessage = conversationsForADate[index + 1];
showAvatar = shouldShowAvatar(message, nextMessage);
}
return { showAvatar, ...message };
});
export const findUndeliveredMessage = (messageInbox, { content }) =>
Object.values(messageInbox).filter(
message => message.content === content && message.status === 'in_progress'
);
export const getNonDeletedMessages = ({ messages }) => {
return messages.filter(
item => !(item.content_attributes && item.content_attributes.deleted)
);
};

View File

@@ -0,0 +1,25 @@
import { getters } from './getters';
import { actions } from './actions';
import { mutations } from './mutations';
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
isCreating: false,
},
lastMessageId: null,
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,116 @@
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { findUndeliveredMessage } from './helpers';
export const mutations = {
clearConversations($state) {
$state.conversations = {};
},
pushMessageToConversation($state, message) {
const { id, status, message_type: type } = message;
const messagesInbox = $state.conversations;
const isMessageIncoming = type === MESSAGE_TYPE.INCOMING;
const isTemporaryMessage = status === 'in_progress';
if (!isMessageIncoming || isTemporaryMessage) {
messagesInbox[id] = message;
return;
}
const [messageInConversation] = findUndeliveredMessage(
messagesInbox,
message
);
if (!messageInConversation) {
messagesInbox[id] = message;
} else {
// [VITE] instead of leaving undefined behind, we remove it completely
// remove the temporary message and replace it with the new message
// messagesInbox[messageInConversation.id] = undefined;
delete messagesInbox[messageInConversation.id];
messagesInbox[id] = message;
}
},
updateAttachmentMessageStatus($state, { message, tempId }) {
const { id } = message;
const messagesInbox = $state.conversations;
const messageInConversation = messagesInbox[tempId];
if (messageInConversation) {
// [VITE] instead of leaving undefined behind, we remove it completely
// remove the temporary message and replace it with the new message
// messagesInbox[tempId] = undefined;
delete messagesInbox[tempId];
messagesInbox[id] = { ...message };
}
},
setConversationUIFlag($state, uiFlags) {
$state.uiFlags = {
...$state.uiFlags,
...uiFlags,
};
},
setConversationListLoading($state, status) {
$state.uiFlags.isFetchingList = status;
},
setMessagesInConversation($state, payload) {
if (!payload.length) {
$state.uiFlags.allMessagesLoaded = true;
return;
}
payload.forEach(message => {
$state.conversations[message.id] = message;
});
},
setMissingMessagesInConversation($state, payload) {
$state.conversation = payload;
},
updateMessage($state, { id, content_attributes }) {
$state.conversations[id] = {
...$state.conversations[id],
content_attributes: {
...($state.conversations[id].content_attributes || {}),
...content_attributes,
},
};
},
updateMessageMeta($state, { id, meta }) {
const message = $state.conversations[id];
if (!message) return;
const newMeta = message.meta ? { ...message.meta, ...meta } : { ...meta };
message.meta = { ...newMeta };
},
deleteMessage($state, id) {
delete $state.conversations[id];
// [VITE] In Vue 3 proxy objects, we can't delete properties by setting them to undefined
// Instead, we have to use the delete operator
// $state.conversations[id] = undefined;
},
toggleAgentTypingStatus($state, { status }) {
$state.uiFlags.isAgentTyping = status === 'on';
},
setMetaUserLastSeenAt($state, lastSeen) {
$state.meta.userLastSeenAt = lastSeen;
},
setLastMessageId($state) {
const { conversations } = $state;
const lastMessage = Object.values(conversations).pop();
if (!lastMessage) return;
const { id } = lastMessage;
$state.lastMessageId = id;
},
};

View File

@@ -0,0 +1,59 @@
import {
SET_CONVERSATION_ATTRIBUTES,
UPDATE_CONVERSATION_ATTRIBUTES,
CLEAR_CONVERSATION_ATTRIBUTES,
} from '../types';
import { getConversationAPI } from '../../api/conversation';
const state = {
id: '',
status: '',
};
export const getters = {
getConversationParams: $state => $state,
};
export const actions = {
getAttributes: async ({ commit }) => {
try {
const { data } = await getConversationAPI();
const { contact_last_seen_at: lastSeen } = data;
commit(SET_CONVERSATION_ATTRIBUTES, data);
commit('conversation/setMetaUserLastSeenAt', lastSeen, { root: true });
} catch (error) {
// Ignore error
}
},
update({ commit }, data) {
commit(UPDATE_CONVERSATION_ATTRIBUTES, data);
},
clearConversationAttributes: ({ commit }) => {
commit('CLEAR_CONVERSATION_ATTRIBUTES');
},
};
export const mutations = {
[SET_CONVERSATION_ATTRIBUTES]($state, data) {
$state.id = data.id;
$state.status = data.status;
},
[UPDATE_CONVERSATION_ATTRIBUTES]($state, data) {
if (data.id === $state.id) {
$state.id = data.id;
$state.status = data.status;
}
},
[CLEAR_CONVERSATION_ATTRIBUTES]($state) {
$state.id = '';
$state.status = '';
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,32 @@
import conversationLabels from '../../api/conversationLabels';
const state = {};
export const getters = {};
export const actions = {
create: async (_, label) => {
try {
await conversationLabels.create(label);
} catch (error) {
// Ignore error
}
},
destroy: async (_, label) => {
try {
await conversationLabels.destroy(label);
} catch (error) {
// Ignore error
}
},
};
export const mutations = {};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,19 @@
import events from 'widget/api/events';
const actions = {
create: async (_, { name }) => {
try {
await events.create(name);
} catch (error) {
// Ignore error
}
},
};
export default {
namespaced: true,
state: {},
getters: {},
actions,
mutations: {},
};

View File

@@ -0,0 +1,59 @@
import MessageAPI from '../../api/message';
const state = {
uiFlags: {
isUpdating: false,
},
};
export const getters = {
getUIFlags: $state => $state.uiFlags,
};
export const actions = {
update: async (
{ commit, dispatch, getters: { getUIFlags: uiFlags } },
{ email, messageId, submittedValues }
) => {
if (uiFlags.isUpdating) {
return;
}
commit('toggleUpdateStatus', true);
try {
await MessageAPI.update({
email,
messageId,
values: submittedValues,
});
commit(
'conversation/updateMessage',
{
id: messageId,
content_attributes: {
submitted_email: email,
submitted_values: email ? null : submittedValues,
},
},
{ root: true }
);
dispatch('contacts/get', {}, { root: true });
} catch (error) {
// Ignore error
}
commit('toggleUpdateStatus', false);
},
};
export const mutations = {
toggleUpdateStatus($state, status) {
$state.uiFlags.isUpdating = status;
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@@ -0,0 +1,85 @@
import { actions } from '../../agent';
import { agents } from './data';
import { getFromCache, setCache } from 'shared/helpers/cache';
import { getAvailableAgents } from 'widget/api/agent';
let commit = vi.fn();
vi.mock('widget/helpers/axios');
vi.mock('widget/api/agent');
vi.mock('shared/helpers/cache');
describe('#actions', () => {
describe('#fetchAvailableAgents', () => {
const websiteToken = 'test-token';
beforeEach(() => {
commit = vi.fn();
vi.clearAllMocks();
});
it('returns cached data if available', async () => {
getFromCache.mockReturnValue(agents);
await actions.fetchAvailableAgents({ commit }, websiteToken);
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`
);
expect(getAvailableAgents).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit).toHaveBeenCalledWith('setAgents', agents);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
});
it('fetches and caches data if no cache available', async () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, websiteToken);
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`
);
expect(getAvailableAgents).toHaveBeenCalledWith(websiteToken);
expect(setCache).toHaveBeenCalledWith(
`chatwoot_available_agents_${websiteToken}`,
agents
);
expect(commit).toHaveBeenCalledWith('setAgents', agents);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
});
it('sends correct actions if API is success', async () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setAgents', agents],
['setError', false],
['setHasFetched', true],
]);
});
it('sends correct actions if API is error', async () => {
getFromCache.mockReturnValue(null);
getAvailableAgents.mockRejectedValue({
message: 'Authentication required',
});
await actions.fetchAvailableAgents({ commit }, 'Hi');
expect(commit.mock.calls).toEqual([
['setError', true],
['setHasFetched', true],
]);
});
});
describe('#updatePresence', () => {
it('commits the correct presence value', () => {
actions.updatePresence({ commit }, { 1: 'online' });
expect(commit.mock.calls).toEqual([['updatePresence', { 1: 'online' }]]);
});
});
});

View File

@@ -0,0 +1,26 @@
export const agents = [
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'online',
},
{
id: 2,
name: 'Xavier',
avatar_url: '',
availability_status: 'offline',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'online',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'online',
},
];

View File

@@ -0,0 +1,30 @@
import { getters } from '../../agent';
import { agents } from './data';
describe('#getters', () => {
it('availableAgents', () => {
const state = {
records: agents,
};
expect(getters.availableAgents(state)).toEqual([
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'online',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'online',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'online',
},
]);
});
});

View File

@@ -0,0 +1,61 @@
import { mutations } from '../../agent';
import { agents } from './data';
describe('#mutations', () => {
describe('#setAgents', () => {
it('set agent records', () => {
const state = { records: [] };
mutations.setAgents(state, agents);
expect(state.records).toEqual(agents);
});
});
describe('#setError', () => {
it('set error flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setError(state, true);
expect(state.uiFlags.isError).toEqual(true);
});
});
describe('#setHasFetched', () => {
it('set fetched flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setHasFetched(state, true);
expect(state.uiFlags.hasFetched).toEqual(true);
});
});
describe('#updatePresence', () => {
it('updates agent presence', () => {
const state = { records: agents };
mutations.updatePresence(state, { 1: 'busy', 2: 'online' });
expect(state.records).toEqual([
{
id: 1,
name: 'John',
avatar_url: '',
availability_status: 'busy',
},
{
id: 2,
name: 'Xavier',
avatar_url: '',
availability_status: 'online',
},
{
id: 3,
name: 'Pranav',
avatar_url: '',
availability_status: 'offline',
},
{
id: 4,
name: 'Nithin',
avatar_url: '',
availability_status: 'offline',
},
]);
});
});
});

View File

@@ -0,0 +1,41 @@
import { actions } from '../../appConfig';
const commit = vi.fn();
describe('#actions', () => {
describe('#setReferrerHost', () => {
it('creates actions properly', () => {
actions.setReferrerHost({ commit }, 'www.chatwoot.com');
expect(commit.mock.calls).toEqual([
['SET_REFERRER_HOST', 'www.chatwoot.com'],
]);
});
});
describe('#setBubbleVisibility', () => {
it('creates actions properly', () => {
actions.setBubbleVisibility({ commit }, false);
expect(commit.mock.calls).toEqual([['SET_BUBBLE_VISIBILITY', false]]);
});
});
describe('#setWidgetColor', () => {
it('creates actions properly', () => {
actions.setWidgetColor({ commit }, '#eaeaea');
expect(commit.mock.calls).toEqual([['SET_WIDGET_COLOR', '#eaeaea']]);
});
});
describe('#setColorScheme', () => {
it('creates actions for dark mode properly', () => {
actions.setColorScheme({ commit }, 'dark');
expect(commit.mock.calls).toEqual([['SET_COLOR_SCHEME', 'dark']]);
});
});
describe('#setRouteTransitionState', () => {
it('creates actions properly', () => {
actions.setRouteTransitionState({ commit }, false);
expect(commit.mock.calls).toEqual([['SET_ROUTE_UPDATE_STATE', false]]);
});
});
});

View File

@@ -0,0 +1,70 @@
import { getters } from '../../appConfig';
describe('#getters', () => {
describe('#getWidgetColor', () => {
it('returns correct value', () => {
const state = { widgetColor: '#00bcd4' };
expect(getters.getWidgetColor(state)).toEqual('#00bcd4');
});
});
describe('#getReferrerHost', () => {
it('returns correct value', () => {
const state = { referrerHost: 'www.chatwoot.com' };
expect(getters.getReferrerHost(state)).toEqual('www.chatwoot.com');
});
});
describe('#getShowUnreadMessagesDialog', () => {
it('returns correct value', () => {
const state = { showUnreadMessagesDialog: true };
expect(getters.getShowUnreadMessagesDialog(state)).toEqual(true);
});
});
describe('#getAvailableMessage', () => {
it('returns correct value', () => {
const state = { availableMessage: 'We reply quickly' };
expect(getters.getAvailableMessage(state)).toEqual('We reply quickly');
});
});
describe('#getWelcomeHeading', () => {
it('returns correct value', () => {
const state = { welcomeTitle: 'Hello!' };
expect(getters.getWelcomeHeading(state)).toEqual('Hello!');
});
});
describe('#getWelcomeTagline', () => {
it('returns correct value', () => {
const state = { welcomeDescription: 'Welcome to our site' };
expect(getters.getWelcomeTagline(state)).toEqual('Welcome to our site');
});
});
describe('#getShouldShowFilePicker', () => {
it('returns correct value', () => {
const state = { enableFileUpload: true };
expect(getters.getShouldShowFilePicker(state)).toEqual(true);
});
});
describe('#getShouldShowEmojiPicker', () => {
it('returns correct value', () => {
const state = { enableEmojiPicker: true };
expect(getters.getShouldShowEmojiPicker(state)).toEqual(true);
});
});
describe('#getCanUserEndConversation', () => {
it('returns correct value', () => {
const state = { enableEndConversation: true };
expect(getters.getCanUserEndConversation(state)).toEqual(true);
});
});
describe('#getUnavailableMessage', () => {
it('returns correct value', () => {
const state = { unavailableMessage: 'We are offline' };
expect(getters.getUnavailableMessage(state)).toEqual('We are offline');
});
});
describe('#getIsUpdatingRoute', () => {
it('returns correct value', () => {
const state = { isUpdatingRoute: true };
expect(getters.getIsUpdatingRoute(state)).toEqual(true);
});
});
});

View File

@@ -0,0 +1,43 @@
import { mutations } from '../../appConfig';
describe('#mutations', () => {
describe('#SET_REFERRER_HOST', () => {
it('sets referrer host properly', () => {
const state = { referrerHost: '' };
mutations.SET_REFERRER_HOST(state, 'www.chatwoot.com');
expect(state.referrerHost).toEqual('www.chatwoot.com');
});
});
describe('#SET_BUBBLE_VISIBILITY', () => {
it('sets bubble visibility properly', () => {
const state = { hideMessageBubble: false };
mutations.SET_BUBBLE_VISIBILITY(state, true);
expect(state.hideMessageBubble).toEqual(true);
});
});
describe('#SET_WIDGET_COLOR', () => {
it('sets widget color properly', () => {
const state = { widgetColor: '' };
mutations.SET_WIDGET_COLOR(state, '#00bcd4');
expect(state.widgetColor).toEqual('#00bcd4');
});
});
describe('#SET_COLOR_SCHEME', () => {
it('sets dark mode properly', () => {
const state = { darkMode: 'light' };
mutations.SET_COLOR_SCHEME(state, 'dark');
expect(state.darkMode).toEqual('dark');
});
});
describe('#SET_ROUTE_UPDATE_STATE', () => {
it('sets dark mode properly', () => {
const state = { isUpdatingRoute: false };
mutations.SET_ROUTE_UPDATE_STATE(state, true);
expect(state.isUpdatingRoute).toEqual(true);
});
});
});

View File

@@ -0,0 +1,170 @@
import { mutations, actions, getters } from '../articles';
import { getMostReadArticles } from 'widget/api/article';
import { getFromCache, setCache } from 'shared/helpers/cache';
vi.mock('widget/api/article');
vi.mock('shared/helpers/cache');
describe('Vuex Articles Module', () => {
let state;
beforeEach(() => {
state = {
records: [],
uiFlags: {
isError: false,
hasFetched: false,
isFetching: false,
},
};
});
describe('Mutations', () => {
it('sets articles correctly', () => {
const articles = [{ id: 1 }, { id: 2 }];
mutations.setArticles(state, articles);
expect(state.records).toEqual(articles);
});
it('sets error flag correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
});
it('sets fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
});
it('does not mutate records when no articles are provided', () => {
const previousState = { ...state };
mutations.setArticles(state, []);
expect(state.records).toEqual(previousState.records);
});
it('toggles the error state correctly', () => {
mutations.setError(state, true);
expect(state.uiFlags.isError).toBe(true);
mutations.setError(state, false);
expect(state.uiFlags.isError).toBe(false);
});
it('toggles the fetching state correctly', () => {
mutations.setIsFetching(state, true);
expect(state.uiFlags.isFetching).toBe(true);
mutations.setIsFetching(state, false);
expect(state.uiFlags.isFetching).toBe(false);
});
});
describe('Actions', () => {
describe('#fetch', () => {
const slug = 'test-slug';
const locale = 'en';
const articles = [
{ id: 1, title: 'Test' },
{ id: 2, title: 'Test 2' },
];
let commit;
beforeEach(() => {
commit = vi.fn();
vi.clearAllMocks();
});
it('returns cached data if available', async () => {
getFromCache.mockReturnValue(articles);
await actions.fetch({ commit }, { slug, locale });
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`
);
expect(getMostReadArticles).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit).toHaveBeenCalledWith('setArticles', articles);
expect(commit).toHaveBeenCalledWith('setError', false);
});
it('fetches and caches data if no cache available', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
await actions.fetch({ commit }, { slug, locale });
expect(getFromCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`
);
expect(getMostReadArticles).toHaveBeenCalledWith(slug, locale);
expect(setCache).toHaveBeenCalledWith(
`chatwoot_most_read_articles_${slug}_${locale}`,
articles
);
expect(commit).toHaveBeenCalledWith('setArticles', articles);
expect(commit).toHaveBeenCalledWith('setError', false);
});
it('handles API errors correctly', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockRejectedValue(new Error('API Error'));
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('does not mutate state when fetching returns an empty payload', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: [] } });
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', false);
expect(commit).not.toHaveBeenCalledWith(
'setArticles',
expect.any(Array)
);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
it('sets loading state during fetch', async () => {
getFromCache.mockReturnValue(null);
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
await actions.fetch({ commit }, { slug, locale });
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
});
it('sets error state when fetching fails', async () => {
const commit = vi.fn();
getMostReadArticles.mockRejectedValueOnce(new Error('Network error'));
await actions.fetch(
{ commit },
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
);
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
expect(commit).toHaveBeenCalledWith('setError', true);
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
});
});
describe('Getters', () => {
it('returns uiFlags correctly', () => {
const result = getters.uiFlags(state);
expect(result).toEqual(state.uiFlags);
});
it('returns popularArticles correctly', () => {
const result = getters.popularArticles(state);
expect(result).toEqual(state.records);
});
});
});

View File

@@ -0,0 +1,273 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../campaign';
import { campaigns } from './data';
import { getFromCache, setCache } from 'shared/helpers/cache';
const commit = vi.fn();
const dispatch = vi.fn();
vi.mock('widget/helpers/axios');
vi.mock('shared/helpers/cache');
import campaignTimer from 'widget/helpers/campaignTimer';
vi.mock('widget/helpers/campaignTimer', () => ({
default: {
initTimers: vi.fn().mockReturnValue({ mock: true }),
},
}));
describe('#actions', () => {
describe('#fetchCampaigns', () => {
beforeEach(() => {
commit.mockClear();
getFromCache.mockClear();
setCache.mockClear();
API.get.mockClear();
campaignTimer.initTimers.mockClear();
});
it('uses cached data when available', async () => {
getFromCache.mockReturnValue(campaigns);
await actions.fetchCampaigns(
{ commit },
{
websiteToken: 'XDsafmADasd',
currentURL: 'https://chatwoot.com',
isInBusinessHours: true,
}
);
expect(getFromCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
60 * 60 * 1000
);
expect(API.get).not.toHaveBeenCalled();
expect(setCache).not.toHaveBeenCalled();
expect(commit.mock.calls).toEqual([
['setCampaigns', campaigns],
['setError', false],
]);
expect(campaignTimer.initTimers).toHaveBeenCalledWith(
{
campaigns: [
{
id: 11,
timeOnPage: '20',
url: 'https://chatwoot.com',
triggerOnlyDuringBusinessHours: false,
},
],
},
'XDsafmADasd'
);
});
it('fetches and caches data when cache is not available', async () => {
getFromCache.mockReturnValue(null);
API.get.mockResolvedValue({ data: campaigns });
await actions.fetchCampaigns(
{ commit },
{
websiteToken: 'XDsafmADasd',
currentURL: 'https://chatwoot.com',
isInBusinessHours: true,
}
);
expect(getFromCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
60 * 60 * 1000
);
expect(API.get).toHaveBeenCalled();
expect(setCache).toHaveBeenCalledWith(
'chatwoot_campaigns_XDsafmADasd',
campaigns
);
expect(commit.mock.calls).toEqual([
['setCampaigns', campaigns],
['setError', false],
]);
expect(campaignTimer.initTimers).toHaveBeenCalledWith(
{
campaigns: [
{
id: 11,
timeOnPage: '20',
url: 'https://chatwoot.com',
triggerOnlyDuringBusinessHours: false,
},
],
},
'XDsafmADasd'
);
});
it('sends correct actions if API is error', async () => {
getFromCache.mockReturnValue(null);
API.get.mockRejectedValue({ message: 'Authentication required' });
await actions.fetchCampaigns(
{ commit },
{
websiteToken: 'XDsafmADasd',
currentURL: 'https://www.chatwoot.com',
isInBusinessHours: true,
}
);
expect(commit.mock.calls).toEqual([['setError', true]]);
});
});
describe('#initCampaigns', () => {
const actionParams = {
websiteToken: 'XDsafmADasd',
currentURL: 'https://chatwoot.com',
};
it('sends correct actions if campaigns are empty', async () => {
await actions.initCampaigns(
{
dispatch,
getters: { getCampaigns: [], getUIFlags: { hasFetched: false } },
},
actionParams
);
expect(dispatch.mock.calls).toEqual([['fetchCampaigns', actionParams]]);
expect(campaignTimer.initTimers).not.toHaveBeenCalled();
});
it('do not refetch if the campaigns are fetched once', async () => {
await actions.initCampaigns(
{
dispatch,
getters: { getCampaigns: [], getUIFlags: { hasFetched: true } },
},
actionParams
);
expect(dispatch.mock.calls).toEqual([]);
expect(campaignTimer.initTimers).not.toHaveBeenCalled();
});
it('resets time if campaigns are available', async () => {
await actions.initCampaigns(
{
dispatch,
getters: {
getCampaigns: campaigns,
getUIFlags: { hasFetched: true },
},
},
actionParams
);
expect(dispatch.mock.calls).toEqual([]);
expect(campaignTimer.initTimers).toHaveBeenCalledWith(
{
campaigns: [
{
id: 11,
timeOnPage: '20',
url: 'https://chatwoot.com',
triggerOnlyDuringBusinessHours: false,
},
],
},
'XDsafmADasd'
);
});
});
describe('#startCampaign', () => {
it('reset campaign if campaign id is not present in the campaign list', async () => {
API.get.mockResolvedValue({ data: campaigns });
await actions.startCampaign(
{
dispatch,
getters: { getCampaigns: campaigns },
commit,
rootState: {
appConfig: { isWidgetOpen: true },
},
},
{ campaignId: 32 }
);
});
it('start campaign if campaign id passed', async () => {
API.get.mockResolvedValue({ data: campaigns });
await actions.startCampaign(
{
dispatch,
getters: { getCampaigns: campaigns },
commit,
rootState: {
appConfig: { isWidgetOpen: false },
},
},
{ campaignId: 1 }
);
expect(commit.mock.calls).toEqual([['setActiveCampaign', campaigns[0]]]);
});
});
describe('#executeCampaign', () => {
it('sends correct actions if execute campaign API is success', async () => {
const params = { campaignId: 12, websiteToken: 'XDsafmADasd' };
API.post.mockResolvedValue({});
await actions.executeCampaign({ commit }, params);
expect(commit.mock.calls).toEqual([
[
'conversation/setConversationUIFlag',
{
isCreating: true,
},
{
root: true,
},
],
['setCampaignExecuted', true],
['setActiveCampaign', {}],
[
'conversation/setConversationUIFlag',
{
isCreating: false,
},
{
root: true,
},
],
]);
});
it('sends correct actions if execute campaign API is failed', async () => {
const params = { campaignId: 12, websiteToken: 'XDsafmADasd' };
API.post.mockRejectedValue({ message: 'Authentication required' });
await actions.executeCampaign({ commit }, params);
expect(commit.mock.calls).toEqual([
[
'conversation/setConversationUIFlag',
{
isCreating: true,
},
{
root: true,
},
],
['setError', true],
[
'conversation/setConversationUIFlag',
{
isCreating: false,
},
{
root: true,
},
],
]);
});
});
describe('#resetCampaign', () => {
it('sends correct actions if execute campaign API is success', async () => {
API.post.mockResolvedValue({});
await actions.resetCampaign({ commit });
expect(commit.mock.calls).toEqual([
['setCampaignExecuted', false],
['setActiveCampaign', {}],
]);
});
});
});

View File

@@ -0,0 +1,86 @@
export const campaigns = [
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 11,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'GitX',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
},
message: 'Begin your onboarding campaign with a welcome message',
enabled: true,
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
{
id: 12,
title: 'Thanks',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'nithin@chatwoot.com',
available_name: 'Nithin',
},
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
];

View File

@@ -0,0 +1,133 @@
import { getters } from '../../campaign';
import { campaigns } from './data';
vi.mock('widget/store/index.js', () => ({
default: {},
}));
describe('#getters', () => {
it('getCampaigns', () => {
const state = {
records: campaigns,
};
expect(getters.getCampaigns(state)).toEqual([
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 11,
title: 'Onboarding Campaign',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'GitX',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
},
message: 'Begin your onboarding campaign with a welcome message',
enabled: true,
trigger_rules: {
url: 'https://chatwoot.com',
time_on_page: '20',
},
created_at: '2021-05-03T08:15:35.828Z',
updated_at: '2021-05-03T08:15:35.828Z',
},
{
id: 12,
title: 'Thanks',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'nithin@chatwoot.com',
available_name: 'Nithin',
},
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
]);
});
it('getActiveCampaign', () => {
const state = {
records: campaigns[0],
};
expect(getters.getCampaigns(state)).toEqual({
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
inbox: {
id: 37,
channel_id: 1,
name: 'Chatwoot',
channel_type: 'Channel::WebWidget',
},
sender: {
account_id: 1,
availability_status: 'offline',
confirmed: true,
email: 'sojan@chatwoot.com',
available_name: 'Sojan',
id: 10,
name: 'Sojan',
},
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
});
});
});

View File

@@ -0,0 +1,40 @@
import { mutations } from '../../campaign';
import { campaigns } from './data';
vi.mock('widget/store/index.js', () => ({
default: {},
}));
describe('#mutations', () => {
describe('#setCampaigns', () => {
it('set campaign records', () => {
const state = { records: [], uiFlags: {} };
mutations.setCampaigns(state, campaigns);
expect(state.records).toEqual(campaigns);
expect(state.uiFlags.hasFetched).toEqual(true);
});
});
describe('#setError', () => {
it('set error flag', () => {
const state = { records: [], uiFlags: {} };
mutations.setError(state, true);
expect(state.uiFlags.isError).toEqual(true);
});
});
describe('#setActiveCampaign', () => {
it('set active campaign', () => {
const state = { records: [] };
mutations.setActiveCampaign(state, campaigns[0]);
expect(state.activeCampaign).toEqual(campaigns[0]);
});
});
describe('#setCampaignExecuted', () => {
it('set campaign executed flag', () => {
const state = { records: [], uiFlags: {}, campaignHasExecuted: false };
mutations.setCampaignExecuted(state, true);
expect(state.campaignHasExecuted).toEqual(true);
});
});
});

View File

@@ -0,0 +1,84 @@
import { API } from 'widget/helpers/axios';
import { sendMessage } from 'widget/helpers/utils';
import { actions } from '../../contacts';
const commit = vi.fn();
const dispatch = vi.fn();
vi.mock('widget/helpers/axios');
vi.mock('widget/helpers/utils', () => ({
sendMessage: vi.fn(),
}));
describe('#actions', () => {
describe('#setUser', () => {
it('sends correct actions if contact object is refreshed ', async () => {
const user = {
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
};
vi.spyOn(API, 'patch').mockResolvedValue({
data: { widget_auth_token: 'token' },
});
await actions.setUser({ commit, dispatch }, { identifier: 1, user });
expect(sendMessage.mock.calls).toEqual([
[{ data: { widgetAuthToken: 'token' }, event: 'setAuthCookie' }],
]);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([
['get'],
['conversation/clearConversations', {}, { root: true }],
['conversation/fetchOldConversations', {}, { root: true }],
['conversationAttributes/getAttributes', {}, { root: true }],
]);
});
it('sends correct actions if identifierHash is passed ', async () => {
const user = {
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
identifier_hash: '12345',
};
vi.spyOn(API, 'patch').mockResolvedValue({ data: { id: 1 } });
await actions.setUser({ commit, dispatch }, { identifier: 1, user });
expect(sendMessage.mock.calls).toEqual([]);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([
['get'],
['conversation/clearConversations', {}, { root: true }],
['conversation/fetchOldConversations', {}, { root: true }],
['conversationAttributes/getAttributes', {}, { root: true }],
]);
});
it('does not call sendMessage if contact object is not refreshed ', async () => {
const user = {
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
};
API.patch.mockResolvedValue({ data: { id: 1 } });
await actions.setUser({ commit, dispatch }, { identifier: 1, user });
expect(sendMessage.mock.calls).toEqual([]);
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([['get']]);
});
});
describe('#update', () => {
it('sends correct actions', async () => {
const user = {
email: 'thoma@sphadikam.com',
name: 'Adu Thoma',
avatar_url: '',
identifier_hash: 'random_hex_identifier_hash',
};
API.patch.mockResolvedValue({ data: { id: 1 } });
await actions.update({ commit, dispatch }, { identifier: 1, user });
expect(commit.mock.calls).toEqual([]);
expect(dispatch.mock.calls).toEqual([['get']]);
});
});
});

View File

@@ -0,0 +1,21 @@
import { getters } from '../../contacts';
describe('#getters', () => {
it('getCurrentUser', () => {
const user = {
has_email: true,
has_name: true,
avatar_url: '',
identifier_hash: 'malana_hash',
};
const state = {
currentUser: user,
};
expect(getters.getCurrentUser(state)).toEqual({
has_email: true,
has_name: true,
avatar_url: '',
identifier_hash: 'malana_hash',
});
});
});

View File

@@ -0,0 +1,17 @@
import { mutations } from '../../contacts';
describe('#mutations', () => {
describe('#SET_CURRENT_USER', () => {
it('set current user', () => {
const user = {
has_email: true,
has_name: true,
avatar_url: '',
identifier_hash: 'malana_hash',
};
const state = { currentUser: {} };
mutations.SET_CURRENT_USER(state, user);
expect(state.currentUser).toEqual(user);
});
});
});

View File

@@ -0,0 +1,434 @@
import { actions } from '../../conversation/actions';
import getUuid from '../../../../helpers/uuid';
import { API } from 'widget/helpers/axios';
vi.mock('../../../../helpers/uuid');
vi.mock('widget/helpers/axios');
const commit = vi.fn();
const dispatch = vi.fn();
describe('#actions', () => {
describe('#createConversation', () => {
it('sends correct mutations', async () => {
API.post.mockResolvedValue({
data: {
contact: { name: 'contact-name' },
messages: [{ id: 1, content: 'This is a test message' }],
},
});
let windowSpy = vi.spyOn(window, 'window', 'get');
windowSpy.mockImplementation(() => ({
WOOT_WIDGET: {
$root: {
$i18n: {
locale: 'el',
},
},
},
location: {
search: '?param=1',
},
}));
await actions.createConversation(
{ commit },
{ contact: {}, message: 'This is a test message' }
);
expect(commit.mock.calls).toEqual([
['setConversationUIFlag', { isCreating: true }],
[
'pushMessageToConversation',
{ id: 1, content: 'This is a test message' },
],
['setConversationUIFlag', { isCreating: false }],
]);
windowSpy.mockRestore();
});
});
describe('#addOrUpdateMessage', () => {
it('sends correct actions for non-deleted message', () => {
actions.addOrUpdateMessage(
{ commit },
{
id: 1,
content: 'Hey',
content_attributes: {},
}
);
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: 1,
content: 'Hey',
content_attributes: {},
});
});
it('sends correct actions for non-deleted message', () => {
actions.addOrUpdateMessage(
{ commit },
{
id: 1,
content: 'Hey',
content_attributes: { deleted: true },
}
);
expect(commit).toBeCalledWith('deleteMessage', 1);
});
it('plays audio when agent sends a message', () => {
actions.addOrUpdateMessage({ commit }, { id: 1, message_type: 1 });
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: 1,
message_type: 1,
});
});
});
describe('#toggleAgentTyping', () => {
it('sends correct mutations', () => {
actions.toggleAgentTyping({ commit }, { status: true });
expect(commit).toBeCalledWith('toggleAgentTypingStatus', {
status: true,
});
});
});
describe('#sendMessage', () => {
it('sends correct mutations', async () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '1111');
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const windowSpy = vi.spyOn(window, 'window', 'get');
windowSpy.mockImplementation(() => ({
WOOT_WIDGET: {
$root: {
$i18n: {
locale: 'ar',
},
},
},
location: {
search: '?param=1',
},
}));
await actions.sendMessage(
{ commit, dispatch },
{ content: 'hello', replyTo: 124 }
);
spy.mockRestore();
windowSpy.mockRestore();
expect(dispatch).toBeCalledWith('sendMessageWithData', {
attachments: undefined,
content: 'hello',
created_at: 1466424490,
id: '1111',
message_type: 0,
replyTo: 124,
status: 'in_progress',
});
});
});
describe('#sendAttachment', () => {
it('sends correct mutations', () => {
const mockDate = new Date(1466424490000);
getUuid.mockImplementationOnce(() => '1111');
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
const thumbUrl = '';
const attachment = { thumbUrl, fileType: 'file' };
actions.sendAttachment(
{ commit, dispatch },
{ attachment, replyTo: 135 }
);
spy.mockRestore();
expect(commit).toBeCalledWith('pushMessageToConversation', {
id: '1111',
content: undefined,
status: 'in_progress',
created_at: 1466424490,
message_type: 0,
replyTo: 135,
attachments: [
{
thumb_url: '',
data_url: '',
file_type: 'file',
status: 'in_progress',
},
],
});
});
});
describe('#setUserLastSeen', () => {
it('sends correct mutations', async () => {
API.post.mockResolvedValue({ data: { success: true } });
await actions.setUserLastSeen({
commit,
getters: { getConversationSize: 2 },
});
expect(commit.mock.calls[0][0]).toEqual('setMetaUserLastSeenAt');
});
it('sends correct mutations', async () => {
API.post.mockResolvedValue({ data: { success: true } });
await actions.setUserLastSeen({
commit,
getters: { getConversationSize: 0 },
});
expect(commit.mock.calls).toEqual([]);
});
});
describe('#clearConversations', () => {
it('sends correct mutations', () => {
actions.clearConversations({ commit });
expect(commit).toBeCalledWith('clearConversations');
});
});
describe('#fetchOldConversations', () => {
it('sends correct actions', async () => {
API.get.mockResolvedValue({
data: {
payload: [
{
id: 1,
text: 'hey',
content_attributes: {},
},
{
id: 2,
text: 'welcome',
content_attributes: { deleted: true },
},
],
meta: {
contact_last_seen_at: 1466424490,
},
},
});
await actions.fetchOldConversations({ commit }, {});
expect(commit.mock.calls).toEqual([
['setConversationListLoading', true],
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
[
'setMessagesInConversation',
[
{
id: 1,
text: 'hey',
content_attributes: {},
},
],
],
['setConversationListLoading', false],
]);
});
});
describe('#syncLatestMessages', () => {
it('latest message should append to end of list', async () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversations: {
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
conversation_id: 20,
},
463: {
id: 463,
content: 'ss',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
conversation_id: 20,
},
},
lastMessageId: 463,
};
API.get.mockResolvedValue({
data: {
payload: [
{
id: 465,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682504326, // Wednesday, 26 April 2023 10:18:46
conversation_id: 20,
},
],
meta: {
contact_last_seen_at: 1466424490,
},
},
});
await actions.syncLatestMessages({ state, commit }, {});
expect(commit.mock.calls).toEqual([
['conversation/setMetaUserLastSeenAt', 1466424490, { root: true }],
[
'setMissingMessagesInConversation',
{
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682244355,
conversation_id: 20,
},
463: {
id: 463,
content: 'ss',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682490729,
conversation_id: 20,
},
465: {
id: 465,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682504326,
conversation_id: 20,
},
},
],
]);
});
it('old message should insert to exact position', async () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversations: {
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
conversation_id: 20,
},
463: {
id: 463,
content: 'ss',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
conversation_id: 20,
},
},
lastMessageId: 463,
};
API.get.mockResolvedValue({
data: {
payload: [
{
id: 460,
content: 'Hi how are you',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682417926, // Tuesday, 25 April 2023 10:18:46
conversation_id: 20,
},
],
meta: {
contact_last_seen_at: 14664223490,
},
},
});
await actions.syncLatestMessages({ state, commit }, {});
expect(commit.mock.calls).toEqual([
['conversation/setMetaUserLastSeenAt', 14664223490, { root: true }],
[
'setMissingMessagesInConversation',
{
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682244355,
conversation_id: 20,
},
460: {
id: 460,
content: 'Hi how are you',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682417926,
conversation_id: 20,
},
463: {
id: 463,
content: 'ss',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682490729,
conversation_id: 20,
},
},
],
]);
});
it('abort syncing if there is no missing messages ', async () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversation: {
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682244355, // Sunday, 23 April 2023 10:05:55
conversation_id: 20,
},
463: {
id: 463,
content: 'ss',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682490729, // Wednesday, 26 April 2023 06:32:09
conversation_id: 20,
},
},
lastMessageId: 463,
};
API.get.mockResolvedValue({
data: {
payload: [],
meta: {
contact_last_seen_at: 14664223490,
},
},
});
await actions.syncLatestMessages({ state, commit }, {});
expect(commit.mock.calls).toEqual([]);
});
});
});

View File

@@ -0,0 +1,477 @@
import { getters } from '../../conversation/getters';
describe('#getters', () => {
it('getConversation', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
},
};
expect(getters.getConversation(state)).toEqual({
1: {
content: 'hello',
},
});
});
it('getIsCreating', () => {
const state = { uiFlags: { isCreating: true } };
expect(getters.getIsCreating(state)).toEqual(true);
});
it('getConversationSize', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
},
};
expect(getters.getConversationSize(state)).toEqual(1);
});
it('getEarliestMessage', () => {
const state = {
conversations: {
1: {
content: 'hello',
},
2: {
content: 'hello1',
},
},
};
expect(getters.getEarliestMessage(state)).toEqual({
content: 'hello',
});
});
it('uiFlags', () => {
const state = {
uiFlags: {
allMessagesLoaded: false,
isFetchingList: false,
isAgentTyping: false,
},
};
expect(getters.getAllMessagesLoaded(state)).toEqual(false);
expect(getters.getIsFetchingList(state)).toEqual(false);
expect(getters.getIsAgentTyping(state)).toEqual(false);
});
it('getGroupedConversation', () => {
expect(
getters.getGroupedConversation({
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 0,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
3: {
id: 3,
content: 'Hey',
created_at: 1574092218,
message_type: 1,
},
4: {
id: 4,
content: 'Hey',
created_at: 1576340623,
},
5: {
id: 5,
content: 'How may I help you',
created_at: 1576340626,
},
},
})
).toEqual([
{
date: 'Nov 18, 2019',
messages: [
{
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
showAvatar: false,
message_type: 0,
},
{
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
showAvatar: true,
message_type: 0,
},
{
id: 3,
content: 'Hey',
created_at: 1574092218,
showAvatar: true,
message_type: 1,
},
],
},
{
date: 'Dec 14, 2019',
messages: [
{
id: 4,
content: 'Hey',
created_at: 1576340623,
showAvatar: false,
},
{
id: 5,
content: 'How may I help you',
created_at: 1576340626,
showAvatar: true,
},
],
},
]);
expect(
getters.getGroupedConversation({
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 0,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
3: {
id: 3,
content: 'Hey',
created_at: 1574092218,
message_type: 1,
},
4: {
id: 4,
content: 'Hey',
created_at: 1576340623,
},
5: {
id: 5,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
content_attributes: {
submitted_values: [{ name: 'text', value: 'sample text' }],
},
},
6: {
id: 6,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
},
7: {
id: 7,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
content_attributes: {
submitted_values: [{ name: 'text', value: 'sample text' }],
},
},
},
})
).toEqual([
{
date: 'Nov 18, 2019',
messages: [
{
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
showAvatar: false,
message_type: 0,
},
{
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
showAvatar: true,
message_type: 0,
},
{
id: 3,
content: 'Hey',
created_at: 1574092218,
showAvatar: true,
message_type: 1,
},
],
},
{
date: 'Dec 14, 2019',
messages: [
{
id: 4,
content: 'Hey',
created_at: 1576340623,
showAvatar: true,
},
{
id: 5,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
content_attributes: {
submitted_values: [{ name: 'text', value: 'sample text' }],
},
showAvatar: false,
},
{
id: 6,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
showAvatar: true,
},
{
id: 7,
content: 'How may I help you',
created_at: 1576340626,
message_type: 2,
content_type: 'form',
content_attributes: {
submitted_values: [{ name: 'text', value: 'sample text' }],
},
showAvatar: false,
},
],
},
]);
});
describe('getUnreadMessageCount returns', () => {
it('0 if there are no messages and last seen is undefined', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
it('0 if there are no messages and last seen is present', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: Date.now(),
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
it('unread count if there are messages and last seen is before messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 1,
},
},
meta: {
userLastSeenAt: 1474075964,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(2);
});
it('unread count if there are messages and last seen is after messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 1,
},
3: {
id: 3,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
meta: {
userLastSeenAt: 1674075964,
},
};
expect(getters.getUnreadMessageCount(state)).toEqual(0);
});
});
describe('getUnreadTextMessages returns', () => {
it('no messages if there are no messages and last seen is undefined', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: undefined,
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 0 })
).toEqual([]);
});
it('0 if there are no messages and last seen is present', () => {
const state = {
conversations: {},
meta: {
userLastSeenAt: Date.now(),
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 0 })
).toEqual([]);
});
it('only unread text messages from agent if there are messages and last seen is before messages created-at', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 1 })
).toEqual([
{
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
]);
});
it('unread messages omitting seen messages ', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1674075965,
message_type: 1,
},
3: {
id: 3,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
meta: {
userLastSeenAt: 1674075964,
},
};
expect(
getters.getUnreadTextMessages(state, { getUnreadMessageCount: 1 })
).toEqual([
{
id: 2,
content: 'Yes, It makes sense',
created_at: 1674075965,
message_type: 1,
},
]);
});
});
it('getMessageCount', () => {
const state = {
conversations: {
1: {
content: 'hey, how are you?',
},
},
};
expect(getters.getMessageCount(state)).toEqual(1);
});
it('getLastMessage', () => {
const state = {
conversations: {
1: {
id: 1,
content: 'Thanks for the help',
created_at: 1574075964,
message_type: 1,
},
2: {
id: 2,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 1,
},
3: {
id: 3,
content: 'Yes, It makes sense',
created_at: 1574092218,
message_type: 0,
},
},
meta: {
userLastSeenAt: 1674075964,
},
};
expect(getters.getLastMessage(state).id).toEqual(3);
});
});

View File

@@ -0,0 +1,81 @@
import {
findUndeliveredMessage,
createTemporaryMessage,
getNonDeletedMessages,
} from '../../conversation/helpers';
describe('#findUndeliveredMessage', () => {
it('returns message objects if exist', () => {
const conversation = {
1: {
id: 1,
content: 'Hello',
status: 'in_progress',
},
2: {
id: 2,
content: 'Hello',
status: 'sent',
},
3: {
id: 3,
content: 'How may I help you',
status: 'sent',
},
};
expect(
findUndeliveredMessage(conversation, { content: 'Hello' })
).toStrictEqual([{ id: 1, content: 'Hello', status: 'in_progress' }]);
});
});
describe('#createTemporaryMessage', () => {
it('returns message object', () => {
const message = createTemporaryMessage({ content: 'hello' });
expect(message.content).toBe('hello');
expect(message.status).toBe('in_progress');
});
it('returns message object with reply to', () => {
const message = createTemporaryMessage({
content: 'hello',
replyTo: 124,
});
expect(message.content).toBe('hello');
expect(message.status).toBe('in_progress');
expect(message.replyTo).toBe(124);
});
});
describe('#getNonDeletedMessages', () => {
it('returns non-deleted messages', () => {
const messages = [
{
id: 1,
content: 'Hello',
content_attributes: {},
},
{
id: 2,
content: 'Hey',
content_attributes: { deleted: true },
},
{
id: 3,
content: 'How may I help you',
content_attributes: {},
},
];
expect(getNonDeletedMessages({ messages })).toStrictEqual([
{
id: 1,
content: 'Hello',
content_attributes: {},
},
{
id: 3,
content: 'How may I help you',
content_attributes: {},
},
]);
});
});

View File

@@ -0,0 +1,254 @@
import { mutations } from '../../conversation/mutations';
const temporaryMessagePayload = {
content: 'hello',
id: 1,
message_type: 0,
status: 'in_progress',
};
const incomingMessagePayload = {
content: 'hello',
id: 1,
message_type: 0,
status: 'sent',
};
const outgoingMessagePayload = {
content: 'hello',
id: 1,
message_type: 1,
status: 'sent',
};
describe('#mutations', () => {
describe('#pushMessageToConversation', () => {
it('add message to conversation if outgoing', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, outgoingMessagePayload);
expect(state.conversations).toEqual({
1: outgoingMessagePayload,
});
});
it('add message to conversation if message in undelivered', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, temporaryMessagePayload);
expect(state.conversations).toEqual({
1: temporaryMessagePayload,
});
});
it('replaces temporary message in conversation with actual message', () => {
const state = {
conversations: {
rand_id_123: {
content: 'hello',
id: 'rand_id_123',
message_type: 0,
status: 'in_progress',
},
},
};
mutations.pushMessageToConversation(state, incomingMessagePayload);
expect(state.conversations).toEqual({
1: incomingMessagePayload,
});
});
it('adds message in conversation if it is a new message', () => {
const state = { conversations: {} };
mutations.pushMessageToConversation(state, incomingMessagePayload);
expect(state.conversations).toEqual({
1: incomingMessagePayload,
});
});
});
describe('#setConversationListLoading', () => {
it('set status correctly', () => {
const state = { uiFlags: { isFetchingList: false } };
mutations.setConversationListLoading(state, true);
expect(state.uiFlags.isFetchingList).toEqual(true);
});
});
describe('#setConversationUIFlag', () => {
it('set uiFlags correctly', () => {
const state = { uiFlags: { isFetchingList: false } };
mutations.setConversationUIFlag(state, { isCreating: true });
expect(state.uiFlags).toEqual({
isFetchingList: false,
isCreating: true,
});
});
});
describe('#setMessagesInConversation', () => {
it('sets allMessagesLoaded flag if payload is empty', () => {
const state = { uiFlags: { allMessagesLoaded: false } };
mutations.setMessagesInConversation(state, []);
expect(state.uiFlags.allMessagesLoaded).toEqual(true);
});
it('sets messages if payload is not empty', () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversations: {},
};
mutations.setMessagesInConversation(state, [{ id: 1, content: 'hello' }]);
expect(state.conversations).toEqual({
1: { id: 1, content: 'hello' },
});
expect(state.uiFlags.allMessagesLoaded).toEqual(false);
});
});
describe('#toggleAgentTypingStatus', () => {
it('sets isAgentTyping flag to true', () => {
const state = { uiFlags: { isAgentTyping: false } };
mutations.toggleAgentTypingStatus(state, { status: 'on' });
expect(state.uiFlags.isAgentTyping).toEqual(true);
});
it('sets isAgentTyping flag to false', () => {
const state = { uiFlags: { isAgentTyping: true } };
mutations.toggleAgentTypingStatus(state, { status: 'off' });
expect(state.uiFlags.isAgentTyping).toEqual(false);
});
});
describe('#updateAttachmentMessageStatus', () => {
it('Updates status of loading messages if payload is not empty', () => {
const state = {
conversations: {
rand_id_123: {
content: '',
id: 'rand_id_123',
message_type: 0,
status: 'in_progress',
attachment: {
file: '',
file_type: 'image',
},
},
},
};
const message = {
id: '1',
content: '',
status: 'sent',
message_type: 0,
attachments: [
{
file: '',
file_type: 'image',
},
],
};
mutations.updateAttachmentMessageStatus(state, {
message,
tempId: 'rand_id_123',
});
expect(state.conversations).toEqual({
1: {
id: '1',
content: '',
message_type: 0,
status: 'sent',
attachments: [
{
file: '',
file_type: 'image',
},
],
},
});
});
});
describe('#clearConversations', () => {
it('clears the state', () => {
const state = { conversations: { 1: { id: 1 } } };
mutations.clearConversations(state);
expect(state.conversations).toEqual({});
});
});
describe('#deleteMessage', () => {
it('delete the message from conversation', () => {
const state = { conversations: { 1: { id: 1 } } };
mutations.deleteMessage(state, 1);
expect(state.conversations).toEqual({});
});
});
describe('#setMissingMessages', () => {
it('sets messages if payload is not empty', () => {
const state = {
uiFlags: { allMessagesLoaded: false },
conversations: {
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682432667,
conversation_id: 20,
},
464: {
id: 464,
content: 'hey will be back soon',
message_type: 3,
content_type: 'text',
content_attributes: {},
created_at: 1682490729,
conversation_id: 20,
},
},
};
mutations.setMessagesInConversation(state, [
{
id: 455,
content: 'Hey billowing-grass-423 how are you?',
message_type: 3,
content_type: 'text',
content_attributes: {},
created_at: 1682432667,
conversation_id: 20,
},
]);
expect(state.conversations).toEqual({
454: {
id: 454,
content: 'hi',
message_type: 0,
content_type: 'text',
content_attributes: {},
created_at: 1682432667,
conversation_id: 20,
},
455: {
id: 455,
content: 'Hey billowing-grass-423 how are you?',
message_type: 3,
content_type: 'text',
content_attributes: {},
created_at: 1682432667,
conversation_id: 20,
},
464: {
id: 464,
content: 'hey will be back soon',
message_type: 3,
content_type: 'text',
content_attributes: {},
created_at: 1682490729,
conversation_id: 20,
},
});
});
});
});

View File

@@ -0,0 +1,39 @@
import { actions } from '../../conversationAttributes';
import { API } from 'widget/helpers/axios';
const commit = vi.fn();
vi.mock('widget/helpers/axios');
describe('#actions', () => {
describe('#get attributes', () => {
it('sends mutation if api is success', async () => {
API.get.mockResolvedValue({ data: { id: 1, status: 'pending' } });
await actions.getAttributes({ commit });
expect(commit.mock.calls).toEqual([
['SET_CONVERSATION_ATTRIBUTES', { id: 1, status: 'pending' }],
['conversation/setMetaUserLastSeenAt', undefined, { root: true }],
]);
});
it('doesnot send mutation if api is error', async () => {
API.get.mockRejectedValue({ message: 'Invalid Headers' });
await actions.getAttributes({ commit });
expect(commit.mock.calls).toEqual([]);
});
});
describe('#update attributes', () => {
it('sends correct mutations', () => {
actions.update({ commit }, { id: 1, status: 'pending' });
expect(commit).toBeCalledWith('UPDATE_CONVERSATION_ATTRIBUTES', {
id: 1,
status: 'pending',
});
});
});
describe('#clear attributes', () => {
it('sends correct mutations', () => {
actions.clearConversationAttributes({ commit });
expect(commit).toBeCalledWith('CLEAR_CONVERSATION_ATTRIBUTES');
});
});
});

View File

@@ -0,0 +1,14 @@
import { getters } from '../../conversationAttributes';
describe('#getters', () => {
it('getConversationParams', () => {
const state = {
id: 1,
status: 'pending',
};
expect(getters.getConversationParams(state)).toEqual({
id: 1,
status: 'pending',
});
});
});

View File

@@ -0,0 +1,44 @@
import { mutations } from '../../conversationAttributes';
describe('#mutations', () => {
describe('#SET_CONVERSATION_ATTRIBUTES', () => {
it('set status of the conversation', () => {
const state = { id: '', status: '' };
mutations.SET_CONVERSATION_ATTRIBUTES(state, {
id: 1,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'open' });
});
});
describe('#UPDATE_CONVERSATION_ATTRIBUTES', () => {
it('update status if it is same conversation', () => {
const state = { id: 1, status: 'pending' };
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
id: 1,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'open' });
});
it('doesnot update status if it is not the same conversation', () => {
const state = { id: 1, status: 'pending' };
mutations.UPDATE_CONVERSATION_ATTRIBUTES(state, {
id: 2,
status: 'open',
});
expect(state).toEqual({ id: 1, status: 'pending' });
});
});
describe('#CLEAR_CONVERSATION_ATTRIBUTES', () => {
it('clear status if it is same conversation', () => {
const state = { id: 1, status: 'open' };
mutations.CLEAR_CONVERSATION_ATTRIBUTES(state, {
id: 1,
status: 'open',
});
expect(state).toEqual({ id: '', status: '' });
});
});
});

View File

@@ -0,0 +1,64 @@
import { API } from 'widget/helpers/axios';
import { actions } from '../../message';
const commit = vi.fn();
vi.mock('widget/helpers/axios');
describe('#actions', () => {
describe('#update', () => {
it('sends correct actions', async () => {
const user = {
email: 'john@acme.inc',
messageId: 10,
submittedValues: {
email: 'john@acme.inc',
},
};
API.patch.mockResolvedValue({
data: { contact: { pubsub_token: '8npuMUfDgizrwVoqcK1t7FMY' } },
});
await actions.update(
{
commit,
getters: {
getUIFlags: {
isUpdating: false,
},
},
},
user
);
expect(commit.mock.calls).toEqual([
['toggleUpdateStatus', true],
[
'conversation/updateMessage',
{
id: 10,
content_attributes: {
submitted_email: 'john@acme.inc',
submitted_values: null,
},
},
{ root: true },
],
['toggleUpdateStatus', false],
]);
});
it('blocks all new action calls when isUpdating', async () => {
await actions.update(
{
commit,
getters: {
getUIFlags: {
isUpdating: true,
},
},
},
{}
);
expect(commit.mock.calls).toEqual([]);
});
});
});

View File

@@ -0,0 +1,14 @@
import { getters } from '../../message';
describe('#getters', () => {
it('getUIFlags', () => {
const state = {
uiFlags: {
isUpdating: false,
},
};
expect(getters.getUIFlags(state)).toEqual({
isUpdating: false,
});
});
});

View File

@@ -0,0 +1,11 @@
import { mutations } from '../../message';
describe('#mutations', () => {
describe('#toggleUpdateStatus', () => {
it('set update flags', () => {
const state = { uiFlags: { status: '' } };
mutations.toggleUpdateStatus(state, 'sent');
expect(state.uiFlags.isUpdating).toEqual('sent');
});
});
});

View File

@@ -0,0 +1,10 @@
export const CLEAR_CONVERSATION_ATTRIBUTES = 'CLEAR_CONVERSATION_ATTRIBUTES';
export const SET_CONVERSATION_ATTRIBUTES = 'SET_CONVERSATION_ATTRIBUTES';
export const SET_WIDGET_APP_CONFIG = 'SET_WIDGET_APP_CONFIG';
export const SET_WIDGET_COLOR = 'SET_WIDGET_COLOR';
export const SET_COLOR_SCHEME = 'SET_COLOR_SCHEME';
export const UPDATE_CONVERSATION_ATTRIBUTES = 'UPDATE_CONVERSATION_ATTRIBUTES';
export const TOGGLE_WIDGET_OPEN = 'TOGGLE_WIDGET_OPEN';
export const SET_REFERRER_HOST = 'SET_REFERRER_HOST';
export const SET_BUBBLE_VISIBILITY = 'SET_BUBBLE_VISIBILITY';
export const SET_ROUTE_UPDATE_STATE = 'SET_ROUTE_UPDATE_STATE';