Compare commits

..
125 changed files with 2363 additions and 4755 deletions
+1 -1
View File
@@ -634,7 +634,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.6)
rexml (3.3.4)
strscan
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
-19
View File
@@ -32,8 +32,6 @@ class AccountBuilder
end
def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
true
@@ -81,21 +79,4 @@ class AccountBuilder
@user.confirm if @confirmed
@user.save!
end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
domains.split("\n").map(&:strip) if domains.present?
[]
end
end
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
end
def account_user_attributes
[:role, :availability, :auto_offline]
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -4,7 +4,6 @@ import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useFilter } from 'shared/composables/useFilter';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
@@ -17,6 +16,7 @@ import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
@@ -41,6 +41,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -90,15 +91,6 @@ export default {
const conversationListRef = ref(null);
const {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
const getKeyboardListenerParams = () => {
const allConversations = conversationListRef.value.querySelectorAll(
'div.conversations-list div.conversation'
@@ -154,9 +146,6 @@ export default {
return {
uiSettings,
conversationListRef,
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
};
},
data() {
@@ -871,25 +860,6 @@ export default {
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
initializeExistingFilterToModal() {
const statusFilter = this.initializeStatusAndAssigneeFilterToModal(
this.activeStatus,
this.currentUserDetails,
this.activeAssigneeTab
);
if (statusFilter) {
this.appliedFilter.push(statusFilter);
}
const otherFilters = this.initializeInboxTeamAndLabelFilterToModal(
this.conversationInbox,
this.inbox,
this.teamId,
this.activeTeam,
this.label
);
this.appliedFilter.push(...otherFilters);
},
},
};
</script>
@@ -13,7 +13,7 @@ import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
const store = useStore();
const getters = useStoreGetters();
@@ -7,7 +7,7 @@ import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
import AICTAModal from './AICTAModal.vue';
import AIAssistanceModal from './AIAssistanceModal.vue';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
export default {
@@ -1,12 +1,13 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useAI } from 'dashboard/composables/useAI';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import AILoader from './AILoader.vue';
export default {
components: {
AILoader,
},
mixins: [messageFormatterMixin],
props: {
aiOption: {
type: String,
@@ -14,9 +15,12 @@ export default {
},
},
setup() {
const { formatMessage } = useMessageFormatter();
const { draftMessage, processEvent, recordAnalytics } = useAI();
return { draftMessage, processEvent, recordAnalytics, formatMessage };
return {
draftMessage,
processEvent,
recordAnalytics,
};
},
data() {
return {
@@ -5,7 +5,7 @@ import languages from './advancedFilterItems/languages';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
@@ -14,6 +14,7 @@ export default {
components: {
FilterInputBox,
},
mixins: [filterMixin],
props: {
onClose: {
type: Function,
@@ -36,15 +37,6 @@ export default {
default: false,
},
},
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
@@ -75,11 +67,7 @@ export default {
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.setFilterAttributes();
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import BubbleActions from './bubble/Actions.vue';
import BubbleContact from './bubble/Contact.vue';
import BubbleFile from './bubble/File.vue';
@@ -39,6 +39,7 @@ export default {
InstagramStoryReply,
Spinner,
},
mixins: [messageFormatterMixin],
props: {
data: {
type: Object,
@@ -73,12 +74,6 @@ export default {
default: () => ({}),
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showContextMenu: false,
@@ -1,10 +1,11 @@
<script>
import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
export default {
name: 'MessagePreview',
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
@@ -19,12 +20,6 @@ export default {
default: '',
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
computed: {
messageByAgent() {
const { message_type: messageType } = this.message;
@@ -7,7 +7,7 @@ import {
CMD_MUTE_CONVERSATION,
CMD_SEND_TRANSCRIPT,
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
} from '../../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
@@ -17,6 +17,7 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -60,7 +61,12 @@ export default {
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
mixins: [
inboxMixin,
messageFormatterMixin,
fileUploadMixin,
keyboardEventListenerMixins,
],
props: {
popoutReplyBox: {
type: Boolean,
@@ -6,7 +6,7 @@ import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import AgentSelector from './AgentSelector.vue';
import UpdateActions from './UpdateActions.vue';
@@ -48,7 +48,11 @@ const loadLinkedIssue = async () => {
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
}
};
@@ -1,197 +0,0 @@
export const mockAssignableAgents = [
{
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
];
export const mockCurrentChat = {
meta: {
sender: {
additional_attributes: {},
availability_status: 'offline',
email: null,
id: 212,
name: 'Chatwoot',
phone_number: null,
identifier: null,
thumbnail: '',
custom_attributes: {},
last_activity_at: 1723553344,
created_at: 1722588710,
},
channel: 'Channel::WebWidget',
assignee: {
id: 1,
account_id: 1,
availability_status: 'online',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'administrator',
thumbnail: '',
},
hmac_verified: false,
},
id: 138,
messages: [
{
id: 3348,
content: 'Hello, how can I assist you today?',
account_id: 1,
inbox_id: 1,
conversation_id: 138,
message_type: 1,
created_at: 1724398739,
updated_at: '2024-08-23T07:38:59.763Z',
private: false,
status: 'sent',
source_id: null,
content_type: 'text',
content_attributes: {},
sender_type: 'User',
sender_id: 1,
external_source_ids: {},
additional_attributes: {},
processed_message_content: 'Hello, how can I assist you today?',
sentiment: {},
conversation: {
assignee_id: 1,
unread_count: 0,
last_activity_at: 1724398739,
contact_inbox: {
source_id: '5e57317d-053b-4a72-8292-a25b9f29c401',
},
},
sender: {
id: 1,
name: 'John Doe',
available_name: 'John Doe',
avatar_url: '',
type: 'user',
availability_status: 'online',
thumbnail: '',
},
},
],
account_id: 1,
uuid: '69dd6922-2f0c-4317-8796-bbeb3679cead',
additional_attributes: {
browser: {
device_name: 'Unknown',
browser_name: 'Chrome',
platform_name: 'macOS',
browser_version: '127.0.0.0',
platform_version: '10.15.7',
},
referer: 'http://chatwoot.com/widget_tests?dark_mode=auto',
initiated_at: {
timestamp: 'Fri Aug 02 2024 15:21:18 GMT+0530 (India Standard Time)',
},
browser_language: 'en',
},
agent_last_seen_at: 1724400730,
assignee_last_seen_at: 1724400686,
can_reply: true,
contact_last_seen_at: 1723553351,
custom_attributes: {},
inbox_id: 1,
labels: ['billing'],
muted: false,
snoozed_until: null,
status: 'open',
created_at: 1722592278,
timestamp: 1724398739,
first_reply_created_at: 1722592316,
unread_count: 0,
last_non_activity_message: {},
last_activity_at: 1724398739,
priority: null,
waiting_since: 0,
sla_policy_id: 10,
applied_sla: {
id: 143,
sla_id: 10,
sla_status: 'missed',
created_at: 1722592279,
updated_at: 1722874214,
sla_description: '',
sla_name: 'Hacker SLA',
sla_first_response_time_threshold: 600,
sla_next_response_time_threshold: 240,
sla_only_during_business_hours: false,
sla_resolution_time_threshold: 259200,
},
sla_events: [
{
id: 270,
event_type: 'nrt',
meta: {
message_id: 2743,
},
updated_at: 1722592819,
created_at: 1722592819,
},
{
id: 275,
event_type: 'rt',
meta: {},
updated_at: 1722852322,
created_at: 1722852322,
},
],
allMessagesLoaded: false,
dataFetched: true,
};
export const mockTeamsList = [
{
id: 5,
name: 'design',
description: 'design team',
allow_auto_assign: true,
account_id: 1,
is_member: false,
},
];
export const mockActiveLabels = [
{
id: 16,
title: 'billing',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const mockInactiveLabels = [
{
id: 2,
title: 'Feature Request',
description: '',
color: '#D8EA19',
show_on_sidebar: true,
},
];
export const MOCK_FEATURE_FLAGS = {
CRM: 'crm',
AGENT_MANAGEMENT: 'agent_management',
TEAM_MANAGEMENT: 'team_management',
INBOX_MANAGEMENT: 'inbox_management',
REPORTS: 'reports',
LABELS: 'labels',
CANNED_RESPONSES: 'canned_responses',
INTEGRATIONS: 'integrations',
};
@@ -1,83 +0,0 @@
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/localStorage');
vi.mock('dashboard/helper/themeHelper.js');
describe('useAppearanceHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({
t: vi.fn(key => key),
});
window.matchMedia = vi.fn().mockReturnValue({ matches: false });
});
it('should return goToAppearanceHotKeys computed property', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value).toBeDefined();
});
it('should have the correct number of appearance options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
expect(goToAppearanceHotKeys.value.length).toBe(4); // 1 parent + 3 theme options
});
it('should have the correct parent option', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const parentOption = goToAppearanceHotKeys.value.find(
option => option.id === 'appearance_settings'
);
expect(parentOption).toBeDefined();
expect(parentOption.children.length).toBe(3);
});
it('should have the correct theme options', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const themeOptions = goToAppearanceHotKeys.value.filter(
option => option.parent === 'appearance_settings'
);
expect(themeOptions.length).toBe(3);
expect(themeOptions.map(option => option.id)).toEqual([
'light',
'dark',
'auto',
]);
});
it('should call setAppearance when a theme option is selected', () => {
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const lightThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'light'
);
lightThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'light'
);
expect(setColorTheme).toHaveBeenCalledWith(false);
});
it('should handle system dark mode preference', () => {
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const autoThemeOption = goToAppearanceHotKeys.value.find(
option => option.id === 'auto'
);
autoThemeOption.handler();
expect(LocalStorage.set).toHaveBeenCalledWith(
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
'auto'
);
expect(setColorTheme).toHaveBeenCalledWith(true);
});
});
@@ -1,102 +0,0 @@
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('shared/helpers/mitt');
describe('useBulkActionsHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
'bulkActions/getSelectedConversationIds': [],
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
emitter.emit = vi.fn();
});
it('should return bulk actions when conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value.length).toBeGreaterThan(0);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
expect(bulkActionsHotKeys.value).toContainEqual(
expect.objectContaining({
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
})
);
});
it('should include snooze options in bulk actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const snoozeAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_snooze_conversation'
);
expect(snoozeAction).toBeDefined();
expect(snoozeAction.children).toEqual(
Object.values(wootConstants.SNOOZE_OPTIONS)
);
});
it('should create handlers for reopen and resolve actions', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const reopenAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_reopen_conversation'
);
const resolveAction = bulkActionsHotKeys.value.find(
action => action.id === 'bulk_action_resolve_conversation'
);
expect(reopenAction.handler).toBeDefined();
expect(resolveAction.handler).toBeDefined();
reopenAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_REOPEN_CONVERSATION'
);
resolveAction.handler();
expect(emitter.emit).toHaveBeenCalledWith(
'CMD_BULK_ACTION_RESOLVE_CONVERSATION'
);
});
it('should return an empty array when no conversations are selected', () => {
store.getters['bulkActions/getSelectedConversationIds'] = [];
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
expect(bulkActionsHotKeys.value).toEqual([]);
});
});
@@ -1,204 +0,0 @@
import { useConversationHotKeys } from '../useConversationHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
mockAssignableAgents,
mockCurrentChat,
mockTeamsList,
mockActiveLabels,
mockInactiveLabels,
} from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useAI');
vi.mock('dashboard/composables/useAgentsList');
describe('useConversationHotKeys', () => {
let store;
beforeEach(() => {
store = {
dispatch: vi.fn(),
getters: {
getSelectedChat: mockCurrentChat,
'draftMessages/getReplyEditorMode': REPLY_EDITOR_MODES.REPLY,
getContextMenuChatId: null,
'teams/getTeams': mockTeamsList,
'draftMessages/get': vi.fn(),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_conversation' });
useConversationLabels.mockReturnValue({
activeLabels: { value: mockActiveLabels },
inactiveLabels: { value: mockInactiveLabels },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
useAgentsList.mockReturnValue({
agentsList: { value: [] },
assignableAgents: { value: mockAssignableAgents },
});
});
it('should return the correct computed properties', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value).toBeDefined();
});
it('should generate conversation hot keys', () => {
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
});
it('should include AI assist actions when AI integration is enabled', () => {
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeDefined();
});
it('should not include AI assist actions when AI integration is disabled', () => {
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
const { conversationHotKeys } = useConversationHotKeys();
const aiAssistAction = conversationHotKeys.value.find(
action => action.id === 'ai_assist'
);
expect(aiAssistAction).toBeUndefined();
});
it('should dispatch actions when handlers are called', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignAgentAction = conversationHotKeys.value.find(
action => action.id === 'assign_an_agent'
);
expect(assignAgentAction).toBeDefined();
if (assignAgentAction && assignAgentAction.children) {
const childAction = conversationHotKeys.value.find(
action => action.id === assignAgentAction.children[0]
);
if (childAction && childAction.handler) {
childAction.handler({ agentInfo: { id: 2 } });
expect(store.dispatch).toHaveBeenCalledWith('assignAgent', {
conversationId: 1,
agentId: 2,
});
}
}
});
it('should return snooze actions when in snooze context', () => {
store.getters.getContextMenuChatId = 1;
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useRoute.mockReturnValue({ name: 'inbox_conversation' });
const { conversationHotKeys } = useConversationHotKeys();
const snoozeAction = conversationHotKeys.value.find(action =>
action.id.includes('snooze_conversation')
);
expect(snoozeAction).toBeDefined();
});
it('should return the correct label actions when there are active labels', () => {
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeDefined();
});
it('should return only add label actions when there are no active labels', () => {
useConversationLabels.mockReturnValue({
activeLabels: { value: [] },
inactiveLabels: { value: [{ title: 'inactive_label' }] },
addLabelToConversation: vi.fn(),
removeLabelFromConversation: vi.fn(),
});
const { conversationHotKeys } = useConversationHotKeys();
const addLabelAction = conversationHotKeys.value.find(
action => action.id === 'add_a_label_to_the_conversation'
);
const removeLabelAction = conversationHotKeys.value.find(
action => action.id === 'remove_a_label_to_the_conversation'
);
expect(addLabelAction).toBeDefined();
expect(removeLabelAction).toBeUndefined();
});
it('should return the correct team assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignTeamAction = conversationHotKeys.value.find(
action => action.id === 'assign_a_team'
);
expect(assignTeamAction).toBeDefined();
expect(assignTeamAction.children.length).toBe(mockTeamsList.length);
});
it('should return the correct priority assignment actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const assignPriorityAction = conversationHotKeys.value.find(
action => action.id === 'assign_priority'
);
expect(assignPriorityAction).toBeDefined();
expect(assignPriorityAction.children.length).toBe(4);
});
it('should return the correct conversation additional actions', () => {
const { conversationHotKeys } = useConversationHotKeys();
const muteAction = conversationHotKeys.value.find(
action => action.id === 'mute_conversation'
);
const sendTranscriptAction = conversationHotKeys.value.find(
action => action.id === 'send_transcript'
);
expect(muteAction).toBeDefined();
expect(sendTranscriptAction).toBeDefined();
});
it('should return unmute action when conversation is muted', () => {
store.getters.getSelectedChat = { ...mockCurrentChat, muted: true };
const { conversationHotKeys } = useConversationHotKeys();
const unmuteAction = conversationHotKeys.value.find(
action => action.id === 'unmute_conversation'
);
expect(unmuteAction).toBeDefined();
});
it('should not return conversation hot keys when not in conversation or inbox route', () => {
useRoute.mockReturnValue({ name: 'some_other_route' });
const { conversationHotKeys } = useConversationHotKeys();
expect(conversationHotKeys.value.length).toBe(0);
});
});
@@ -1,188 +0,0 @@
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { MOCK_FEATURE_FLAGS } from './fixtures';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/composables/useAdmin');
vi.mock('dashboard/helper/URLHelper');
const mockRoutes = [
{ path: 'accounts/:accountId/dashboard', name: 'dashboard' },
{
path: 'accounts/:accountId/contacts',
name: 'contacts',
featureFlag: MOCK_FEATURE_FLAGS.CRM,
},
{
path: 'accounts/:accountId/settings/agents/list',
name: 'agent_settings',
featureFlag: MOCK_FEATURE_FLAGS.AGENT_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/teams/list',
name: 'team_settings',
featureFlag: MOCK_FEATURE_FLAGS.TEAM_MANAGEMENT,
},
{
path: 'accounts/:accountId/settings/inboxes/list',
name: 'inbox_settings',
featureFlag: MOCK_FEATURE_FLAGS.INBOX_MANAGEMENT,
},
{ path: 'accounts/:accountId/profile/settings', name: 'profile_settings' },
{ path: 'accounts/:accountId/notifications', name: 'notifications' },
{
path: 'accounts/:accountId/reports/overview',
name: 'reports_overview',
featureFlag: MOCK_FEATURE_FLAGS.REPORTS,
},
{
path: 'accounts/:accountId/settings/labels/list',
name: 'label_settings',
featureFlag: MOCK_FEATURE_FLAGS.LABELS,
},
{
path: 'accounts/:accountId/settings/canned-response/list',
name: 'canned_responses',
featureFlag: MOCK_FEATURE_FLAGS.CANNED_RESPONSES,
},
{
path: 'accounts/:accountId/settings/applications',
name: 'applications',
featureFlag: MOCK_FEATURE_FLAGS.INTEGRATIONS,
},
];
describe('useGoToCommandHotKeys', () => {
let store;
beforeEach(() => {
store = {
getters: {
getCurrentAccountId: 1,
'accounts/isFeatureEnabledonAccount': vi.fn().mockReturnValue(true),
},
};
useStore.mockReturnValue(store);
useMapGetter.mockImplementation(key => ({
value: store.getters[key],
}));
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRouter.mockReturnValue({ push: vi.fn() });
useAdmin.mockReturnValue({ isAdmin: { value: true } });
frontendURL.mockImplementation(url => url);
});
it('should return goToCommandHotKeys computed property', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
expect(goToCommandHotKeys.value).toBeDefined();
expect(goToCommandHotKeys.value.length).toBeGreaterThan(0);
});
it('should filter commands based on feature flags', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(
(accountId, flag) => flag !== MOCK_FEATURE_FLAGS.CRM
);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
mockRoutes.forEach(route => {
const command = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes(route.name)
);
if (route.featureFlag === MOCK_FEATURE_FLAGS.CRM) {
expect(command).toBeUndefined();
} else if (!route.featureFlag) {
expect(command).toBeDefined();
}
});
});
it('should filter commands for non-admin users', () => {
useAdmin.mockReturnValue({ isAdmin: { value: false } });
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminOnlyCommands = goToCommandHotKeys.value.filter(
cmd =>
cmd.id.includes('agent_settings') ||
cmd.id.includes('team_settings') ||
cmd.id.includes('inbox_settings')
);
expect(adminOnlyCommands.length).toBe(0);
});
it('should include commands for both admin and agent roles when user is admin', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const adminCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('agent_settings')
);
const agentCommand = goToCommandHotKeys.value.find(cmd =>
cmd.id.includes('profile_settings')
);
expect(adminCommand).toBeDefined();
expect(agentCommand).toBeDefined();
});
it('should translate section and title for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.SECTIONS.')
);
expect(useI18n().t).toHaveBeenCalledWith(
expect.stringContaining('COMMAND_BAR.COMMANDS.')
);
expect(command.section).toBeDefined();
expect(command.title).toBeDefined();
});
});
it('should call router.push with correct URL when handler is called', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(expect.any(String));
});
});
it('should use current account ID in the path', () => {
store.getters.getCurrentAccountId = 42;
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
command.handler();
expect(useRouter().push).toHaveBeenCalledWith(
expect.stringContaining('42')
);
});
});
it('should include icon for each command', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
goToCommandHotKeys.value.forEach(command => {
expect(command.icon).toBeDefined();
});
});
it('should return commands for all enabled features', () => {
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const enabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(enabledFeatureCommands.length).toBeGreaterThan(0);
});
it('should not return commands for disabled features', () => {
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(() => false);
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const disabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
);
expect(disabledFeatureCommands.length).toBe(0);
});
});
@@ -1,37 +0,0 @@
import { useInboxHotKeys } from '../useInboxHotKeys';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
vi.mock('dashboard/composables/useI18n');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/helper/routeHelpers');
vi.mock('shared/helpers/mitt');
describe('useInboxHotKeys', () => {
beforeEach(() => {
useI18n.mockReturnValue({ t: vi.fn(key => key) });
useRoute.mockReturnValue({ name: 'inbox_dashboard' });
isAInboxViewRoute.mockReturnValue(true);
});
it('should return inbox hot keys when on an inbox view route', () => {
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value.length).toBeGreaterThan(0);
expect(inboxHotKeys.value[0].id).toBe('snooze_notification');
});
it('should return an empty array when not on an inbox view route', () => {
isAInboxViewRoute.mockReturnValue(false);
const { inboxHotKeys } = useInboxHotKeys();
expect(inboxHotKeys.value).toEqual([]);
});
it('should have the correct structure for snooze actions', () => {
const { inboxHotKeys } = useInboxHotKeys();
const snoozeNotificationAction = inboxHotKeys.value.find(
action => action.id === 'snooze_notification'
);
expect(snoozeNotificationAction).toBeDefined();
});
});
@@ -1,70 +0,0 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import {
ICON_APPEARANCE,
ICON_LIGHT_MODE,
ICON_DARK_MODE,
ICON_SYSTEM_MODE,
} from 'dashboard/helper/commandbar/icons';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
const getThemeOptions = t => [
{
key: 'light',
label: t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
icon: ICON_LIGHT_MODE,
},
{
key: 'dark',
label: t('COMMAND_BAR.COMMANDS.DARK_MODE'),
icon: ICON_DARK_MODE,
},
{
key: 'auto',
label: t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
icon: ICON_SYSTEM_MODE,
},
];
const setAppearance = theme => {
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
const isOSOnDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
setColorTheme(isOSOnDarkMode);
};
export function useAppearanceHotKeys() {
const { t } = useI18n();
const themeOptions = computed(() => getThemeOptions(t));
const goToAppearanceHotKeys = computed(() => {
const options = themeOptions.value.map(theme => ({
id: theme.key,
title: theme.label,
parent: 'appearance_settings',
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: theme.icon,
handler: () => {
setAppearance(theme.key);
},
}));
return [
{
id: 'appearance_settings',
title: t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: ICON_APPEARANCE,
children: options.map(option => option.id),
},
...options,
];
});
return {
goToAppearanceHotKeys,
};
}
@@ -1,89 +0,0 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
import {
ICON_SNOOZE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from 'dashboard/helper/commandbar/icons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from 'dashboard/helper/commandbar/actions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createEmitHandler = event => () => emitter.emit(event);
const SNOOZE_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
const RESOLVED_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
const OPEN_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: createEmitHandler(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
export function useBulkActionsHotKeys() {
const { t } = useI18n();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const bulkActionsHotKeys = computed(() => {
let actions = [];
if (selectedConversations.value.length > 0) {
actions = [
...SNOOZE_CONVERSATION_BULK_ACTIONS,
...RESOLVED_CONVERSATION_BULK_ACTIONS,
...OPEN_CONVERSATION_BULK_ACTIONS,
];
}
return prepareActions(actions);
});
return {
bulkActionsHotKeys,
};
}
@@ -1,408 +0,0 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'dashboard/composables/route';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import wootConstants from 'dashboard/constants/globals';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
ICON_ASSIGN_PRIORITY,
ICON_ASSIGN_TEAM,
ICON_REMOVE_LABEL,
ICON_PRIORITY_URGENT,
ICON_PRIORITY_HIGH,
ICON_PRIORITY_LOW,
ICON_PRIORITY_MEDIUM,
ICON_PRIORITY_NONE,
ICON_AI_ASSIST,
ICON_AI_SUMMARY,
ICON_AI_SHORTEN,
ICON_AI_EXPAND,
ICON_AI_GRAMMAR,
} from 'dashboard/helper/commandbar/icons';
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
} from 'dashboard/helper/commandbar/actions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from 'dashboard/helper/routeHelpers';
const prepareActions = (actions, t) => {
return actions.map(action => ({
...action,
title: t(action.title),
section: t(action.section),
}));
};
const createPriorityOptions = (t, currentPriority) => {
return [
{
label: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
icon: ICON_PRIORITY_NONE,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
icon: ICON_PRIORITY_URGENT,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
icon: ICON_PRIORITY_HIGH,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
icon: ICON_PRIORITY_MEDIUM,
},
{
label: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
icon: ICON_PRIORITY_LOW,
},
].filter(item => item.key !== currentPriority);
};
const createNonDraftMessageAIAssistActions = (t, replyMode) => {
if (replyMode === REPLY_EDITOR_MODES.REPLY) {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'),
key: 'reply_suggestion',
icon: ICON_AI_ASSIST,
},
];
}
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: ICON_AI_SUMMARY,
},
];
};
const createDraftMessageAIAssistActions = t => {
return [
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'),
key: 'fix_spelling_grammar',
icon: ICON_AI_GRAMMAR,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
icon: ICON_AI_ASSIST,
},
];
};
export function useConversationHotKeys() {
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { isAIIntegrationEnabled } = useAI();
const { agentsList } = useAgentsList();
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const contextMenuChatId = useMapGetter('getContextMenuChatId');
const teams = useMapGetter('teams/getTeams');
const getDraftMessage = useMapGetter('draftMessages/get');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = computed(() => getDraftMessage.value(draftKey.value));
const hasAnAssignedTeam = computed(() => !!currentChat.value?.meta?.team);
const teamsList = computed(() => {
if (hasAnAssignedTeam.value) {
return [{ id: 0, name: t('TEAMS_SETTINGS.LIST.NONE') }, ...teams.value];
}
return teams.value;
});
const onChangeAssignee = action => {
store.dispatch('assignAgent', {
conversationId: currentChat.value.id,
agentId: action.agentInfo.id,
});
};
const onChangePriority = action => {
store.dispatch('assignPriority', {
conversationId: currentChat.value.id,
priority: action.priority.key,
});
};
const onChangeTeam = action => {
store.dispatch('assignTeam', {
conversationId: currentChat.value.id,
teamId: action.teamInfo.id,
});
};
const statusActions = computed(() => {
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
currentChat.value?.status === wootConstants.STATUS_TYPE.SNOOZED;
const isResolved =
currentChat.value?.status === wootConstants.STATUS_TYPE.RESOLVED;
let actions = [];
if (isOpen) {
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
return prepareActions(actions, t);
});
const priorityOptions = computed(() =>
createPriorityOptions(t, currentChat.value?.priority)
);
const assignAgentActions = computed(() => {
const agentOptions = agentsList.value.map(agent => ({
id: `agent-${agent.id}`,
title: agent.name,
parent: 'assign_an_agent',
section: t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
agentInfo: agent,
icon: ICON_ASSIGN_AGENT,
handler: onChangeAssignee,
}));
return [
{
id: 'assign_an_agent',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_AGENT,
children: agentOptions.map(option => option.id),
},
...agentOptions,
];
});
const assignPriorityActions = computed(() => {
const options = priorityOptions.value.map(priority => ({
id: `priority-${priority.key}`,
title: priority.label,
parent: 'assign_priority',
section: t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
priority: priority,
icon: priority.icon,
handler: onChangePriority,
}));
return [
{
id: 'assign_priority',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_PRIORITY,
children: options.map(option => option.id),
},
...options,
];
});
const assignTeamActions = computed(() => {
const teamOptions = teamsList.value.map(team => ({
id: `team-${team.id}`,
title: team.name,
parent: 'assign_a_team',
section: t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
teamInfo: team,
icon: ICON_ASSIGN_TEAM,
handler: onChangeTeam,
}));
return [
{
id: 'assign_a_team',
title: t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_TEAM,
children: teamOptions.map(option => option.id),
},
...teamOptions,
];
});
const addLabelActions = computed(() => {
const availableLabels = inactiveLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'add_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
icon: ICON_ADD_LABEL,
handler: action => addLabelToConversation({ title: action.id }),
}));
return [
...availableLabels,
{
id: 'add_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ADD_LABEL,
children: inactiveLabels.value.map(label => label.title),
},
];
});
const removeLabelActions = computed(() => {
const activeLabelsComputed = activeLabels.value.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'remove_a_label_to_the_conversation',
section: t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
icon: ICON_REMOVE_LABEL,
handler: action => removeLabelFromConversation(action.id),
}));
return [
...activeLabelsComputed,
{
id: 'remove_a_label_to_the_conversation',
title: t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_REMOVE_LABEL,
children: activeLabels.value.map(label => label.title),
},
];
});
const labelActions = computed(() => {
if (activeLabels.value.length) {
return [...addLabelActions.value, ...removeLabelActions.value];
}
return addLabelActions.value;
});
const conversationAdditionalActions = computed(() => {
return prepareActions(
[
currentChat.value.muted ? UNMUTE_ACTION : MUTE_ACTION,
SEND_TRANSCRIPT_ACTION,
],
t
);
});
const AIAssistActions = computed(() => {
const aiOptions = draftMessage.value
? createDraftMessageAIAssistActions(t)
: createNonDraftMessageAIAssistActions(t, replyMode.value);
const options = aiOptions.map(item => ({
id: `ai-assist-${item.key}`,
title: item.label,
parent: 'ai_assist',
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
}));
return [
{
id: 'ai_assist',
title: t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
icon: ICON_AI_ASSIST,
children: options.map(option => option.id),
},
...options,
];
});
const isConversationOrInboxRoute = computed(() => {
return isAConversationRoute(route.name) || isAInboxViewRoute(route.name);
});
const shouldShowSnoozeOption = computed(() => {
return (
isAConversationRoute(route.name, true, false) && contextMenuChatId.value
);
});
const getDefaultConversationHotKeys = computed(() => {
const defaultConversationHotKeys = [
...statusActions.value,
...conversationAdditionalActions.value,
...assignAgentActions.value,
...assignTeamActions.value,
...labelActions.value,
...assignPriorityActions.value,
];
if (isAIIntegrationEnabled.value) {
return [...defaultConversationHotKeys, ...AIAssistActions.value];
}
return defaultConversationHotKeys;
});
const conversationHotKeys = computed(() => {
if (shouldShowSnoozeOption.value) {
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
}
if (isConversationOrInboxRoute.value) {
return getDefaultConversationHotKeys.value;
}
return [];
});
return {
conversationHotKeys,
};
}
@@ -1,37 +0,0 @@
export const summary = {
avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333',
conversations_count: 5000,
incoming_messages_count: 5,
outgoing_messages_count: 3,
previous: {
avg_first_response_time: '89.0',
avg_resolution_time: '145.0',
conversations_count: 4,
incoming_messages_count: 5,
outgoing_messages_count: 4,
resolutions_count: 0,
},
resolutions_count: 3,
};
export const botSummary = {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
};
export const report = {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
};
@@ -1,295 +0,0 @@
import { useAutomation } from '../useAutomation';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from '../useI18n';
import * as automationHelper from 'dashboard/helper/automationHelper';
import {
customAttributes,
agents,
teams,
labels,
statusFilterOptions,
campaigns,
contacts,
inboxes,
languages,
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('../useI18n');
vi.mock('dashboard/helper/automationHelper');
describe('useAutomation', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
'attributes/getAttributes': { value: customAttributes },
'attributes/getAttributesByModel': {
value: model => {
return model === 'conversation_attribute'
? [{ id: 1, name: 'Conversation Attribute' }]
: [{ id: 2, name: 'Contact Attribute' }];
},
},
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
'labels/getLabels': labels,
'teams/getTeams': teams,
'sla/getSLA': slaPolicies,
};
return { value: getterMap[getter] };
});
useI18n.mockReturnValue({ t: key => key });
useAlert.mockReturnValue(vi.fn());
// Mock getConditionOptions for different types
automationHelper.getConditionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'status':
return statusFilterOptions;
case 'team_id':
return teams;
case 'assignee_id':
return agents;
case 'contact':
return contacts;
case 'inbox_id':
return inboxes;
case 'campaigns':
return campaigns;
case 'browser_language':
return languages;
case 'country_code':
return countries;
case 'message_type':
return MESSAGE_CONDITION_VALUES;
default:
return [];
}
});
// Mock getActionOptions for different types
automationHelper.getActionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'add_label':
return labels;
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
case 'send_email_to_team':
return teams;
case 'send_message':
return [];
case 'add_sla':
return slaPolicies;
default:
return [];
}
});
});
it('initializes computed properties correctly', () => {
const {
agents: computedAgents,
campaigns: computedCampaigns,
contacts: computedContacts,
inboxes: computedInboxes,
labels: computedLabels,
teams: computedTeams,
slaPolicies: computedSlaPolicies,
} = useAutomation();
expect(computedAgents.value).toEqual(agents);
expect(computedCampaigns.value).toEqual(campaigns);
expect(computedContacts.value).toEqual(contacts);
expect(computedInboxes.value).toEqual(inboxes);
expect(computedLabels.value).toEqual(labels);
expect(computedTeams.value).toEqual(teams);
expect(computedSlaPolicies.value).toEqual(slaPolicies);
});
it('appends new condition and action correctly', () => {
const { appendNewCondition, appendNewAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
appendNewCondition(mockAutomation);
appendNewAction(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
it('removes filter and action correctly', () => {
const { removeFilter, removeAction } = useAutomation();
const mockAutomation = {
conditions: [{ id: 1 }, { id: 2 }],
actions: [{ id: 1 }, { id: 2 }],
};
removeFilter(mockAutomation, 0);
removeAction(mockAutomation, 0);
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
expect(mockAutomation.conditions[0].id).toBe(2);
expect(mockAutomation.actions[0].id).toBe(2);
});
it('resets filter and action correctly', () => {
const { resetFilter, resetAction } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: 'open',
},
],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
const mockAutomationTypes = {
message_created: {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
},
};
resetFilter(
mockAutomation,
mockAutomationTypes,
0,
mockAutomation.conditions[0]
);
resetAction(mockAutomation, 0);
expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to');
expect(mockAutomation.conditions[0].values).toBe('');
expect(mockAutomation.actions[0].action_params).toEqual([]);
});
it('formats automation correctly', () => {
const { formatAutomation } = useAutomation();
const mockAutomation = {
conditions: [{ attribute_key: 'status', values: ['open'] }],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
const mockAutomationTypes = {};
const mockAutomationActionTypes = [
{ key: 'assign_agent', inputType: 'search_select' },
];
automationHelper.getConditionOptions.mockReturnValue([
{ id: 'open', name: 'open' },
]);
automationHelper.getActionOptions.mockReturnValue([
{ id: 1, name: 'Agent 1' },
]);
const result = formatAutomation(
mockAutomation,
customAttributes,
mockAutomationTypes,
mockAutomationActionTypes
);
expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]);
expect(result.actions[0].action_params).toEqual([
{ id: 1, name: 'Agent 1' },
]);
});
it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes } = useAutomation();
const mockAutomationTypes = {
message_created: { conditions: [] },
conversation_created: { conditions: [] },
conversation_updated: { conditions: [] },
conversation_opened: { conditions: [] },
};
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
manifestCustomAttributes(mockAutomationTypes);
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
2
);
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
Object.values(mockAutomationTypes).forEach(type => {
expect(type.conditions).toHaveLength(0);
});
});
it('gets condition dropdown values correctly', () => {
const { getConditionDropdownValues } = useAutomation();
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
expect(getConditionDropdownValues('team_id')).toEqual(teams);
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
expect(getConditionDropdownValues('contact')).toEqual(contacts);
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
);
});
it('gets action dropdown values correctly', () => {
const { getActionDropdownValues } = useAutomation();
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
});
it('handles event change correctly', () => {
const { onEventChange } = useAutomation();
const mockAutomation = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
onEventChange(mockAutomation);
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(mockAutomation.conditions).toHaveLength(1);
expect(mockAutomation.actions).toHaveLength(1);
});
});
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
@@ -1,61 +0,0 @@
import { ref } from 'vue';
import { useReportMetrics } from '../useReportMetrics';
import { useMapGetter } from 'dashboard/composables/store';
import { summary, botSummary } from './fixtures/reportFixtures';
vi.mock('dashboard/composables/store');
vi.mock('@chatwoot/utils', () => ({
formatTime: vi.fn(time => `formatted_${time}`),
}));
describe('useReportMetrics', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockReturnValue(ref(summary));
});
it('calculates trend correctly', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('conversations_count')).toBe(124900);
expect(calculateTrend('incoming_messages_count')).toBe(0);
expect(calculateTrend('avg_first_response_time')).toBe(123);
});
it('returns 0 for trend when previous value is not available', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('non_existent_key')).toBe(0);
});
it('identifies average metric types correctly', () => {
const { isAverageMetricType } = useReportMetrics();
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
expect(isAverageMetricType('reply_time')).toBe(true);
expect(isAverageMetricType('conversations_count')).toBe(false);
});
it('displays metrics correctly for account', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('conversations_count')).toBe('5,000');
expect(displayMetric('incoming_messages_count')).toBe('5');
});
it('displays the metric for bot', () => {
const customKey = 'getBotSummary';
useMapGetter.mockReturnValue(ref(botSummary));
const { displayMetric } = useReportMetrics(customKey);
expect(displayMetric('bot_resolutions_count')).toBe('10');
expect(displayMetric('bot_handoffs_count')).toBe('20');
});
it('handles non-existent metrics', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('non_existent_key')).toBe('0');
});
});
@@ -1,349 +0,0 @@
import { computed } from 'vue';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from './useI18n';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import {
generateCustomAttributeTypes,
getActionOptions,
getConditionOptions,
getCustomAttributeInputType,
getDefaultConditions,
getDefaultActions,
filterCustomAttributes,
getStandardAttributeInputType,
isCustomAttribute,
generateCustomAttributes,
} from 'dashboard/helper/automationHelper';
/**
* Composable for handling automation-related functionality.
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export function useAutomation() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const slaPolicies = useMapGetter('sla/getSLA');
const booleanFilterOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const statusFilterOptions = computed(() => {
const statusFilters = t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
return [
...Object.keys(statusFilters).map(status => ({
id: status,
name: statusFilters[status].TEXT,
})),
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
];
});
/**
* Handles the event change for an automation.
* @param {Object} automation - The automation object to update.
*/
const onEventChange = automation => {
automation.conditions = getDefaultConditions(automation.event_name);
automation.actions = getDefaultActions();
};
/**
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
* @returns {Array} An array of condition dropdown values.
*/
const getConditionDropdownValues = type => {
return getConditionOptions({
agents: agents.value,
booleanFilterOptions: booleanFilterOptions.value,
campaigns: campaigns.value,
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
statusFilterOptions: statusFilterOptions.value,
teams: teams.value,
languages,
countries,
type,
});
};
/**
* Appends a new condition to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewCondition = automation => {
automation.conditions.push(...getDefaultConditions(automation.event_name));
};
/**
* Appends a new action to the automation.
* @param {Object} automation - The automation object to update.
*/
const appendNewAction = automation => {
automation.actions.push(...getDefaultActions());
};
/**
* Removes a filter from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the filter to remove.
*/
const removeFilter = (automation, index) => {
if (automation.conditions.length <= 1) {
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
} else {
automation.conditions.splice(index, 1);
}
};
/**
* Removes an action from the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to remove.
*/
const removeAction = (automation, index) => {
if (automation.actions.length <= 1) {
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
} else {
automation.actions.splice(index, 1);
}
};
/**
* Resets a filter in the automation.
* @param {Object} automation - The automation object to update.
* @param {Object} automationTypes - The automation types object.
* @param {number} index - The index of the filter to reset.
* @param {Object} currentCondition - The current condition object.
*/
const resetFilter = (
automation,
automationTypes,
index,
currentCondition
) => {
automation.conditions[index].filter_operator = automationTypes[
automation.event_name
].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value;
automation.conditions[index].values = '';
};
/**
* Resets an action in the automation.
* @param {Object} automation - The automation object to update.
* @param {number} index - The index of the action to reset.
*/
const resetAction = (automation, index) => {
automation.actions[index].action_params = [];
};
/**
* This function sets the conditions for automation.
* It help to format the conditions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing conditions to manifest.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @returns {Array} An array of manifested conditions.
*/
const manifestConditions = (
automation,
allCustomAttributes,
automationTypes
) => {
const customAttributes = filterCustomAttributes(allCustomAttributes);
return automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return { ...condition, values: condition.values[0] };
}
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
),
};
});
};
/**
* Gets the action dropdown values for a given type.
* @param {string} type - The type of action.
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
return getActionOptions({
agents: agents.value,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
languages,
type,
});
};
/**
* Generates an array of actions for the automation.
* @param {Object} action - The action object.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array|Object} Generated actions array or object based on input type.
*/
const generateActionsArray = (action, automationActionTypes) => {
const params = action.action_params;
const inputType = automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
return [...getActionDropdownValues(action.action_name)].filter(item =>
[...params].includes(item.id)
);
}
if (inputType === 'team_message') {
return {
team_ids: [...getActionDropdownValues(action.action_name)].filter(
item => [...params[0].team_ids].includes(item.id)
),
message: params[0].message,
};
}
return [...params];
};
/**
* This function sets the actions for automation.
* It help to format the actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing actions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array} An array of manifested actions.
*/
const manifestActions = (automation, automationActionTypes) => {
return automation.actions.map(action => ({
...action,
action_params: action.action_params.length
? generateActionsArray(action, automationActionTypes)
: [],
}));
};
/**
* Formats the automation object for use when we edit the automation.
* It help to format the conditions and actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object to format.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
*/
const formatAutomation = (
automation,
allCustomAttributes,
automationTypes,
automationActionTypes
) => {
return {
...automation,
conditions: manifestConditions(
automation,
allCustomAttributes,
automationTypes
),
actions: manifestActions(automation, automationActionTypes),
};
};
/**
* This function formats the custom attributes for automation types.
* It retrieves custom attributes for conversations and contacts,
* generates custom attribute types, and adds them to the relevant automation types.
* @param {Object} automationTypes - The automation types object to update with custom attributes.
*/
const manifestCustomAttributes = automationTypes => {
const conversationCustomAttributesRaw = getters[
'attributes/getAttributesByModel'
].value('conversation_attribute');
const contactCustomAttributesRaw =
getters['attributes/getAttributesByModel'].value('contact_attribute');
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
conversationCustomAttributesRaw,
'conversation_attribute'
);
const contactCustomAttributeTypes = generateCustomAttributeTypes(
contactCustomAttributesRaw,
'contact_attribute'
);
const manifestedCustomAttributes = generateCustomAttributes(
conversationCustomAttributeTypes,
contactCustomAttributeTypes,
t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
automationTypes.message_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_created.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_updated.conditions.push(
...manifestedCustomAttributes
);
automationTypes.conversation_opened.conditions.push(
...manifestedCustomAttributes
);
};
return {
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
booleanFilterOptions,
statusFilterOptions,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
formatAutomation,
getActionDropdownValues,
manifestCustomAttributes,
};
}
@@ -1,6 +1,6 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
/**
* Composable for handling macro-related functionality
@@ -1,57 +0,0 @@
import { useMapGetter } from 'dashboard/composables/store';
import { formatTime } from '@chatwoot/utils';
/**
* A composable function for report metrics calculations and display.
*
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
* @returns {Object} An object containing utility functions for report metrics.
*/
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
const accountSummary = useMapGetter(accountSummaryKey);
/**
* Calculates the trend percentage for a given metric.
*
* @param {string} key - The key of the metric to calculate trend for.
* @returns {number} The calculated trend percentage, rounded to the nearest integer.
*/
const calculateTrend = key => {
if (!accountSummary.value.previous[key]) return 0;
const diff = accountSummary.value[key] - accountSummary.value.previous[key];
return Math.round((diff / accountSummary.value.previous[key]) * 100);
};
/**
* Checks if a given metric key represents an average metric type.
*
* @param {string} key - The key of the metric to check.
* @returns {boolean} True if the metric is an average type, false otherwise.
*/
const isAverageMetricType = key => {
return [
'avg_first_response_time',
'avg_resolution_time',
'reply_time',
].includes(key);
};
/**
* Formats and displays a metric value based on its type.
*
* @param {string} key - The key of the metric to display.
* @returns {string} The formatted metric value as a string.
*/
const displayMetric = key => {
if (isAverageMetricType(key)) {
return formatTime(accountSummary.value[key]);
}
return Number(accountSummary.value[key] || '').toLocaleString();
};
return {
calculateTrend,
isAverageMetricType,
displayMetric,
};
}
@@ -1,70 +0,0 @@
export const DEFAULT_MESSAGE_CREATED_CONDITION = [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_OTHER_CONDITION = [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
export const DEFAULT_ACTIONS = [
{
action_name: 'assign_agent',
action_params: [],
},
];
export const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
},
{
id: 'low',
name: 'Low',
},
{
id: 'medium',
name: 'Medium',
},
{
id: 'high',
name: 'High',
},
{
id: 'urgent',
name: 'Urgent',
},
];
@@ -3,16 +3,41 @@ import {
OPERATOR_TYPES_3,
OPERATOR_TYPES_4,
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
import filterQueryGenerator from './filterQueryGenerator';
import actionQueryGenerator from './actionQueryGenerator';
const MESSAGE_CONDITION_VALUES = [
{
id: 'incoming',
name: 'Incoming Message',
},
{
id: 'outgoing',
name: 'Outgoing Message',
},
];
export const PRIORITY_CONDITION_VALUES = [
{
id: 'nil',
name: 'None',
},
{
id: 'low',
name: 'Low',
},
{
id: 'medium',
name: 'Medium',
},
{
id: 'high',
name: 'High',
},
{
id: 'urgent',
name: 'Urgent',
},
];
export const getCustomAttributeInputType = key => {
const customAttributeMap = {
@@ -173,16 +198,45 @@ export const getFileName = (action, files = []) => {
export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION;
return [
{
attribute_key: 'message_type',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
}
if (eventName === 'conversation_opened') {
return DEFAULT_CONVERSATION_OPENED_CONDITION;
return [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
}
return DEFAULT_OTHER_CONDITION;
return [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
},
];
};
export const getDefaultActions = () => {
return DEFAULT_ACTIONS;
return [
{
action_name: 'assign_agent',
action_params: [],
},
];
};
export const filterCustomAttributes = customAttributes => {
@@ -243,100 +297,3 @@ export const generateCustomAttributes = (
}
return customAttributes;
};
/**
* Get attributes for a given key from automation types.
* @param {Object} automationTypes - Object containing automation types.
* @param {string} key - The key to get attributes for.
* @returns {Array} Array of condition objects for the given key.
*/
export const getAttributes = (automationTypes, key) => {
return automationTypes[key].conditions;
};
/**
* Get the automation type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the automation type for.
* @returns {Object} The automation type object.
*/
export const getAutomationType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
condition => condition.key === key
);
};
/**
* Get the input type for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the input type for.
* @returns {string} The input type.
*/
export const getInputType = (
allCustomAttributes,
automationTypes,
automation,
key
) => {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getCustomAttributeInputType(customAttribute.attribute_display_type);
}
const type = getAutomationType(automationTypes, automation, key);
return type.inputType;
};
/**
* Get operators for a given key.
* @param {Array} allCustomAttributes - Array of all custom attributes.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} mode - The mode ('edit' or other).
* @param {string} key - The key to get operators for.
* @returns {Array} Array of operators.
*/
export const getOperators = (
allCustomAttributes,
automationTypes,
automation,
mode,
key
) => {
if (mode === 'edit') {
const customAttribute = isACustomAttribute(allCustomAttributes, key);
if (customAttribute) {
return getOperatorTypes(customAttribute.attribute_display_type);
}
}
const type = getAutomationType(automationTypes, automation, key);
return type.filterOperators;
};
/**
* Get the custom attribute type for a given key.
* @param {Object} automationTypes - Object containing automation types.
* @param {Object} automation - The automation object.
* @param {string} key - The key to get the custom attribute type for.
* @returns {string} The custom attribute type.
*/
export const getCustomAttributeType = (automationTypes, automation, key) => {
return automationTypes[automation.event_name].conditions.find(
i => i.key === key
).customAttributeType;
};
/**
* Determine if an action input should be shown.
* @param {Array} automationActionTypes - Array of automation action type objects.
* @param {string} action - The action to check.
* @returns {boolean} True if the action input should be shown, false otherwise.
*/
export const showActionInput = (automationActionTypes, action) => {
if (action === 'send_email_to_team' || action === 'send_message')
return false;
const type = automationActionTypes.find(i => i.key === action).inputType;
return !!type;
};
@@ -0,0 +1,314 @@
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { validateAutomation } from 'dashboard/helper/validations';
import {
generateCustomAttributeTypes,
getActionOptions,
getConditionOptions,
getCustomAttributeInputType,
getOperatorTypes,
isACustomAttribute,
getFileName,
getDefaultConditions,
getDefaultActions,
filterCustomAttributes,
generateAutomationPayload,
getStandardAttributeInputType,
isCustomAttribute,
generateCustomAttributes,
} from 'dashboard/helper/automationHelper';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
export default {
computed: {
...mapGetters({
agents: 'agents/getAgents',
campaigns: 'campaigns/getAllCampaigns',
contacts: 'contacts/getContacts',
inboxes: 'inboxes/getInboxes',
labels: 'labels/getLabels',
teams: 'teams/getTeams',
slaPolicies: 'sla/getSLA',
}),
booleanFilterOptions() {
return [
{
id: true,
name: this.$t('FILTER.ATTRIBUTE_LABELS.TRUE'),
},
{
id: false,
name: this.$t('FILTER.ATTRIBUTE_LABELS.FALSE'),
},
];
},
statusFilterOptions() {
const statusFilters = this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
return [
...Object.keys(statusFilters).map(status => {
return {
id: status,
name: statusFilters[status].TEXT,
};
}),
{
id: 'all',
name: this.$t('CHAT_LIST.FILTER_ALL'),
},
];
},
},
methods: {
getFileName,
onEventChange() {
this.automation.conditions = getDefaultConditions(
this.automation.event_name
);
this.automation.actions = getDefaultActions();
},
getAttributes(key) {
return this.automationTypes[key].conditions;
},
getInputType(key) {
const customAttribute = isACustomAttribute(this.allCustomAttributes, key);
if (customAttribute) {
return getCustomAttributeInputType(
customAttribute.attribute_display_type
);
}
const type = this.getAutomationType(key);
return type.inputType;
},
getOperators(key) {
if (this.mode === 'edit') {
const customAttribute = isACustomAttribute(
this.allCustomAttributes,
key
);
if (customAttribute) {
return getOperatorTypes(customAttribute.attribute_display_type);
}
}
const type = this.getAutomationType(key);
return type.filterOperators;
},
getAutomationType(key) {
return this.automationTypes[this.automation.event_name].conditions.find(
condition => condition.key === key
);
},
getCustomAttributeType(key) {
const type = this.automationTypes[
this.automation.event_name
].conditions.find(i => i.key === key).customAttributeType;
return type;
},
getConditionDropdownValues(type) {
const {
agents,
allCustomAttributes: customAttributes,
booleanFilterOptions,
campaigns,
contacts,
inboxes,
statusFilterOptions,
teams,
} = this;
return getConditionOptions({
agents,
booleanFilterOptions,
campaigns,
contacts,
customAttributes,
inboxes,
statusFilterOptions,
teams,
languages,
countries,
type,
});
},
appendNewCondition() {
this.automation.conditions.push(
...getDefaultConditions(this.automation.event_name)
);
},
appendNewAction() {
this.automation.actions.push(...getDefaultActions());
},
removeFilter(index) {
if (this.automation.conditions.length <= 1) {
useAlert(this.$t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
} else {
this.automation.conditions.splice(index, 1);
}
},
removeAction(index) {
if (this.automation.actions.length <= 1) {
useAlert(this.$t('AUTOMATION.ACTION.DELETE_MESSAGE'));
} else {
this.automation.actions.splice(index, 1);
}
},
submitAutomation() {
// we assign it to this.errors so that it can be accessed in the template
// it is supposed to be declared in the data function
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
resetFilter(index, currentCondition) {
this.automation.conditions[index].filter_operator = this.automationTypes[
this.automation.event_name
].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value;
this.automation.conditions[index].values = '';
},
showUserInput(type) {
return !(type === 'is_present' || type === 'is_not_present');
},
showActionInput(action) {
if (action === 'send_email_to_team' || action === 'send_message')
return false;
const type = this.automationActionTypes.find(
i => i.key === action
).inputType;
return !!type;
},
resetAction(index) {
this.automation.actions[index].action_params = [];
},
manifestConditions(automation) {
const customAttributes = filterCustomAttributes(this.allCustomAttributes);
const conditions = automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
this.automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return {
...condition,
values: condition.values[0],
};
}
if (inputType === 'comma_separated_plain_text') {
return {
...condition,
values: condition.values.join(','),
};
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [
...this.getConditionDropdownValues(condition.attribute_key),
].filter(item => [...condition.values].includes(item.id)),
};
});
return conditions;
},
generateActionsArray(action) {
const params = action.action_params;
let actionParams = [];
const inputType = this.automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
actionParams = [
...this.getActionDropdownValues(action.action_name),
].filter(item => [...params].includes(item.id));
} else if (inputType === 'team_message') {
actionParams = {
team_ids: [
...this.getActionDropdownValues(action.action_name),
].filter(item => [...params[0].team_ids].includes(item.id)),
message: params[0].message,
};
} else actionParams = [...params];
return actionParams;
},
manifestActions(automation) {
let actionParams = [];
const actions = automation.actions.map(action => {
if (action.action_params.length) {
actionParams = this.generateActionsArray(action);
}
return {
...action,
action_params: actionParams,
};
});
return actions;
},
formatAutomation(automation) {
this.automation = {
...automation,
conditions: this.manifestConditions(automation),
actions: this.manifestActions(automation),
};
},
getActionDropdownValues(type) {
const { agents, labels, teams, slaPolicies } = this;
return getActionOptions({
agents,
labels,
teams,
slaPolicies,
languages,
type,
});
},
manifestCustomAttributes() {
const conversationCustomAttributesRaw = this.$store.getters[
'attributes/getAttributesByModel'
]('conversation_attribute');
const contactCustomAttributesRaw =
this.$store.getters['attributes/getAttributesByModel'](
'contact_attribute'
);
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
conversationCustomAttributesRaw,
'conversation_attribute'
);
const contactCustomAttributeTypes = generateCustomAttributeTypes(
contactCustomAttributesRaw,
'contact_attribute'
);
let manifestedCustomAttributes = generateCustomAttributes(
conversationCustomAttributeTypes,
contactCustomAttributeTypes,
this.$t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
this.$t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
this.automationTypes.message_created.conditions.push(
...manifestedCustomAttributes
);
this.automationTypes.conversation_created.conditions.push(
...manifestedCustomAttributes
);
this.automationTypes.conversation_updated.conditions.push(
...manifestedCustomAttributes
);
this.automationTypes.conversation_opened.conditions.push(
...manifestedCustomAttributes
);
},
},
};
@@ -0,0 +1,51 @@
import { mapGetters } from 'vuex';
import { formatTime } from '@chatwoot/utils';
export default {
props: {
accountSummaryKey: {
type: String,
default: 'getAccountSummary',
},
},
computed: {
...mapGetters({
accountReport: 'getAccountReports',
}),
accountSummary() {
return this.$store.getters[this.accountSummaryKey];
},
},
methods: {
calculateTrend(key) {
if (!this.accountSummary.previous[key]) return 0;
const diff = this.accountSummary[key] - this.accountSummary.previous[key];
return Math.round((diff / this.accountSummary.previous[key]) * 100);
},
displayMetric(key) {
if (this.isAverageMetricType(key)) {
return formatTime(this.accountSummary[key]);
}
return Number(this.accountSummary[key] || '').toLocaleString();
},
displayInfoText(key) {
if (this.metrics[this.currentSelection].KEY !== key) {
return '';
}
if (this.isAverageMetricType(key)) {
const total = this.accountReport.data
.map(item => item.count)
.reduce((prev, curr) => prev + curr, 0);
return `${this.metrics[this.currentSelection].INFO_TEXT} ${total}`;
}
return '';
},
isAverageMetricType(key) {
return [
'avg_first_response_time',
'avg_resolution_time',
'reply_time',
].includes(key);
},
},
};
@@ -0,0 +1,136 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import reportMixin from '../reportMixin';
import reportFixtures from './reportMixinFixtures';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('reportMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
getAccountSummary: () => reportFixtures.summary,
getBotSummary: () => reportFixtures.botSummary,
getAccountReports: () => reportFixtures.report,
};
store = new Vuex.Store({ getters });
});
it('display the metric for account', async () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
await wrapper.setProps({
accountSummaryKey: 'getAccountSummary',
});
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min 18 Sec'
);
});
it('display the metric for bot', async () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
await wrapper.setProps({
accountSummaryKey: 'getBotSummary',
});
expect(wrapper.vm.displayMetric('bot_resolutions_count')).toEqual('10');
expect(wrapper.vm.displayMetric('bot_handoffs_count')).toEqual('20');
});
it('display the metric', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayMetric('conversations_count')).toEqual('5,000');
expect(wrapper.vm.displayMetric('avg_first_response_time')).toEqual(
'3 Min 18 Sec'
);
});
it('calculate the trend', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.calculateTrend('conversations_count')).toEqual(124900);
expect(wrapper.vm.calculateTrend('resolutions_count')).toEqual(0);
});
it('display info text', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
data() {
return {
currentSelection: 0,
};
},
computed: {
metrics() {
return [
{
DESC: '( Avg )',
INFO_TEXT: 'Total number of conversations used for computation:',
KEY: 'avg_first_response_time',
NAME: 'First Response Time',
},
];
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayInfoText('avg_first_response_time')).toEqual(
'Total number of conversations used for computation: 4'
);
});
it('do not display info text', () => {
const Component = {
render() {},
title: 'TestComponent',
mixins: [reportMixin],
data() {
return {
currentSelection: 0,
};
},
computed: {
metrics() {
return [
{
DESC: '( Total )',
INFO_TEXT: '',
KEY: 'conversation_count',
NAME: 'Conversations',
},
{
DESC: '( Avg )',
INFO_TEXT: 'Total number of conversations used for computation:',
KEY: 'avg_first_response_time',
NAME: 'First Response Time',
},
];
},
},
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.displayInfoText('conversation_count')).toEqual('');
expect(wrapper.vm.displayInfoText('incoming_messages_count')).toEqual('');
});
});
@@ -0,0 +1,37 @@
export default {
summary: {
avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333',
conversations_count: 5000,
incoming_messages_count: 5,
outgoing_messages_count: 3,
previous: {
avg_first_response_time: '89.0',
avg_resolution_time: '145.0',
conversations_count: 4,
incoming_messages_count: 5,
outgoing_messages_count: 4,
resolutions_count: 0,
},
resolutions_count: 3,
},
botSummary: {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
},
report: {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
},
};
@@ -1,7 +1,7 @@
<script>
import { useAlert } from 'dashboard/composables';
import { mapGetters } from 'vuex';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import AddCannedModal from 'dashboard/routes/dashboard/settings/canned/AddCanned.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { conversationUrl, frontendURL } from '../../../helper/URLHelper';
@@ -18,6 +18,7 @@ export default {
TranslateModal,
MenuItem,
},
mixins: [messageFormatterMixin],
props: {
message: {
type: Object,
@@ -36,12 +37,6 @@ export default {
default: () => ({}),
},
},
setup() {
const { getPlainText } = useMessageFormatter();
return {
getPlainText,
};
},
data() {
return {
isCannedResponseModalOpen: false,
@@ -1,12 +1,15 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { dynamicTime } from 'shared/helpers/timeHelper';
export default {
components: {
Thumbnail,
},
mixins: [messageFormatterMixin],
props: {
id: {
type: Number,
@@ -25,12 +28,6 @@ export default {
default: 0,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
showDeleteModal: false,
@@ -1,11 +1,12 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import ReadMore from './ReadMore.vue';
export default {
components: {
ReadMore,
},
mixins: [messageFormatterMixin],
props: {
author: {
type: String,
@@ -20,13 +21,6 @@ export default {
default: '',
},
},
setup() {
const { formatMessage, highlightContent } = useMessageFormatter();
return {
formatMessage,
highlightContent,
};
},
data() {
return {
isOverflowing: false,
@@ -6,7 +6,7 @@ import { useI18n } from 'dashboard/composables/useI18n';
import { useEmitter } from 'dashboard/composables/emitter';
import { getUnixTime } from 'date-fns';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events';
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
@@ -0,0 +1,65 @@
import {
ICON_APPEARANCE,
ICON_LIGHT_MODE,
ICON_DARK_MODE,
ICON_SYSTEM_MODE,
} from './CommandBarIcons';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
export default {
computed: {
themeOptions() {
return [
{
key: 'light',
label: this.$t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
icon: ICON_LIGHT_MODE,
},
{
key: 'dark',
label: this.$t('COMMAND_BAR.COMMANDS.DARK_MODE'),
icon: ICON_DARK_MODE,
},
{
key: 'auto',
label: this.$t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
icon: ICON_SYSTEM_MODE,
},
];
},
goToAppearanceHotKeys() {
const options = this.themeOptions.map(theme => ({
id: theme.key,
title: theme.label,
parent: 'appearance_settings',
section: this.$t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: theme.icon,
handler: () => {
this.setAppearance(theme.key);
},
}));
return [
{
id: 'appearance_settings',
title: this.$t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
section: this.$t('COMMAND_BAR.SECTIONS.APPEARANCE'),
icon: ICON_APPEARANCE,
children: options.map(option => option.id),
},
...options,
];
},
},
methods: {
setAppearance(theme) {
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
const isOSOnDarkMode = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
setColorTheme(isOSOnDarkMode);
},
},
};
@@ -0,0 +1,86 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
CMD_BULK_ACTION_REOPEN_CONVERSATION,
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
} from './commandBarBusEvents';
import {
ICON_SNOOZE_CONVERSATION,
ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION,
} from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from './commandBarActions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
export const SNOOZE_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
...createSnoozeHandlers(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
'bulk_action_snooze_conversation',
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
),
];
export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_reopen_conversation',
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION,
handler: () => emitter.emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
},
];
export const OPEN_CONVERSATION_BULK_ACTIONS = [
{
id: 'bulk_action_resolve_conversation',
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION,
handler: () => emitter.emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
},
];
export default {
computed: {
...mapGetters({
selectedConversations: 'bulkActions/getSelectedConversationIds',
}),
bulkActionsHotKeys() {
let actions = [];
if (this.selectedConversations.length > 0) {
actions = [
...SNOOZE_CONVERSATION_BULK_ACTIONS,
...RESOLVED_CONVERSATION_BULK_ACTIONS,
...OPEN_CONVERSATION_BULK_ACTIONS,
];
}
return this.prepareActions(actions);
},
},
watch: {
selectedConversations() {
this.setCommandbarData();
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -8,7 +8,7 @@ import {
CMD_SEND_TRANSCRIPT,
CMD_SNOOZE_CONVERSATION,
CMD_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/events';
} from './commandBarBusEvents';
import {
ICON_MUTE_CONVERSATION,
@@ -17,7 +17,7 @@ import {
ICON_SEND_TRANSCRIPT,
ICON_SNOOZE_CONVERSATION,
ICON_UNMUTE_CONVERSATION,
} from 'dashboard/helper/commandbar/icons';
} from './CommandBarIcons';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -46,7 +46,6 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
{
id: 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS),
},
@@ -1,85 +1,118 @@
<script setup>
<script>
import '@chatwoot/ninja-keys';
import { ref, computed, watchEffect, onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useTrack } from 'dashboard/composables';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAppearanceHotKeys } from 'dashboard/composables/commands/useAppearanceHotKeys';
import { useInboxHotKeys } from 'dashboard/composables/commands/useInboxHotKeys';
import { useGoToCommandHotKeys } from 'dashboard/composables/commands/useGoToCommandHotKeys';
import { useBulkActionsHotKeys } from 'dashboard/composables/commands/useBulkActionsHotKeys';
import { useConversationHotKeys } from 'dashboard/composables/commands/useConversationHotKeys';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useAI } from 'dashboard/composables/useAI';
import { useAgentsList } from 'dashboard/composables/useAgentsList';
import wootConstants from 'dashboard/constants/globals';
import { GENERAL_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
import inboxHotKeysMixin from './inboxHotKeys';
import goToCommandHotKeys from './goToCommandHotKeys';
import appearanceHotKeys from './appearanceHotKeys';
import { GENERAL_EVENTS } from '../../../helper/AnalyticsHelper/events';
const store = useStore();
const track = useTrack();
const { t } = useI18n();
export default {
mixins: [
conversationHotKeysMixin,
bulkActionsHotKeysMixin,
inboxHotKeysMixin,
appearanceHotKeys,
goToCommandHotKeys,
],
setup() {
// used in conversationHotKeysMixin
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const ninjakeys = ref(null);
const { isAIIntegrationEnabled } = useAI();
const { agentsList, assignableAgents } = useAgentsList();
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
const selectedSnoozeType = ref(null);
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
const { inboxHotKeys } = useInboxHotKeys();
const { goToCommandHotKeys } = useGoToCommandHotKeys();
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
const { conversationHotKeys } = useConversationHotKeys();
const placeholder = computed(() => t('COMMAND_BAR.SEARCH_PLACEHOLDER'));
const hotKeys = computed(() => [
...inboxHotKeys.value,
...goToCommandHotKeys.value,
...goToAppearanceHotKeys.value,
...bulkActionsHotKeys.value,
...conversationHotKeys.value,
]);
const setCommandBarData = () => {
ninjakeys.value.data = hotKeys.value;
return {
agentsList,
assignableAgents,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
isAIIntegrationEnabled,
};
},
data() {
return {
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
selectedSnoozeType: null,
};
},
computed: {
placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
},
accountId() {
return this.$store.getters.getCurrentAccountId;
},
routeName() {
return this.$route.name;
},
hotKeys() {
return [
...this.inboxHotKeys,
...this.conversationHotKeys,
...this.bulkActionsHotKeys,
...this.goToCommandHotKeys,
...this.goToAppearanceHotKeys,
];
},
},
watch: {
routeName() {
this.setCommandbarData();
},
},
mounted() {
this.setCommandbarData();
},
methods: {
setCommandbarData() {
this.$refs.ninjakeys.data = this.hotKeys;
},
onSelected(item) {
const {
detail: {
action: { title = null, section = null, id = null } = {},
} = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.selectedSnoozeType =
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
this.selectedSnoozeType = null;
}
this.$track(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
this.setCommandbarData();
},
onClosed() {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
this.selectedSnoozeType !==
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
this.$store.dispatch('setContextMenuChatId', null);
}
},
},
};
const onSelected = item => {
const {
detail: { action: { title = null, section = null, id = null } = {} } = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
selectedSnoozeType.value = wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
selectedSnoozeType.value = null;
}
track(GENERAL_EVENTS.COMMAND_BAR, {
section,
action: title,
});
setCommandBarData();
};
const onClosed = () => {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
selectedSnoozeType.value !== wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
store.dispatch('setContextMenuChatId', null);
}
};
watchEffect(() => {
if (ninjakeys.value) {
ninjakeys.value.data = hotKeys.value;
}
});
onMounted(setCommandBarData);
</script>
<!-- eslint-disable vue/attribute-hyphenation -->
@@ -0,0 +1,413 @@
import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import {
ICON_ADD_LABEL,
ICON_ASSIGN_AGENT,
ICON_ASSIGN_PRIORITY,
ICON_ASSIGN_TEAM,
ICON_REMOVE_LABEL,
ICON_PRIORITY_URGENT,
ICON_PRIORITY_HIGH,
ICON_PRIORITY_LOW,
ICON_PRIORITY_MEDIUM,
ICON_PRIORITY_NONE,
ICON_AI_ASSIST,
ICON_AI_SUMMARY,
ICON_AI_SHORTEN,
ICON_AI_EXPAND,
ICON_AI_GRAMMAR,
} from './CommandBarIcons';
import {
OPEN_CONVERSATION_ACTIONS,
SNOOZE_CONVERSATION_ACTIONS,
RESOLVED_CONVERSATION_ACTIONS,
SEND_TRANSCRIPT_ACTION,
UNMUTE_ACTION,
MUTE_ACTION,
} from './commandBarActions';
import {
isAConversationRoute,
isAInboxViewRoute,
} from '../../../helper/routeHelpers';
export default {
watch: {
assignableAgents() {
this.setCommandbarData();
},
currentChat() {
this.setCommandbarData();
},
teamsList() {
this.setCommandbarData();
},
activeLabels() {
this.setCommandbarData();
},
draftMessage() {
this.setCommandbarData();
},
replyMode() {
this.setCommandbarData();
},
contextMenuChatId() {
this.setCommandbarData();
},
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode',
contextMenuChatId: 'getContextMenuChatId',
teams: 'teams/getTeams',
}),
draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey);
},
draftKey() {
return `draft-${this.conversationId}-${this.replyMode}`;
},
inboxId() {
return this.currentChat?.inbox_id;
},
conversationId() {
return this.currentChat?.id;
},
hasAnAssignedTeam() {
return !!this.currentChat?.meta?.team;
},
teamsList() {
if (this.hasAnAssignedTeam) {
return [
{ id: 0, name: this.$t('TEAMS_SETTINGS.LIST.NONE') },
...this.teams,
];
}
return this.teams;
},
statusActions() {
const isOpen =
this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
const isSnoozed =
this.currentChat?.status === wootConstants.STATUS_TYPE.SNOOZED;
const isResolved =
this.currentChat?.status === wootConstants.STATUS_TYPE.RESOLVED;
let actions = [];
if (isOpen) {
actions = [
...OPEN_CONVERSATION_ACTIONS,
...SNOOZE_CONVERSATION_ACTIONS,
];
} else if (isResolved || isSnoozed) {
actions = RESOLVED_CONVERSATION_ACTIONS;
}
return this.prepareActions(actions);
},
priorityOptions() {
return [
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
key: null,
icon: ICON_PRIORITY_NONE,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
key: 'urgent',
icon: ICON_PRIORITY_URGENT,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
key: 'high',
icon: ICON_PRIORITY_HIGH,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
key: 'medium',
icon: ICON_PRIORITY_MEDIUM,
},
{
label: this.$t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
key: 'low',
icon: ICON_PRIORITY_LOW,
},
].filter(item => item.key !== this.currentChat?.priority);
},
assignAgentActions() {
const agentOptions = this.agentsList.map(agent => ({
id: `agent-${agent.id}`,
title: agent.name,
parent: 'assign_an_agent',
section: this.$t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
agentInfo: agent,
icon: ICON_ASSIGN_AGENT,
handler: this.onChangeAssignee,
}));
return [
{
id: 'assign_an_agent',
title: this.$t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
section: this.$t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_AGENT,
children: agentOptions.map(option => option.id),
},
...agentOptions,
];
},
assignPriorityActions() {
const options = this.priorityOptions.map(priority => ({
id: `priority-${priority.key}`,
title: priority.label,
parent: 'assign_priority',
section: this.$t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
priority: priority,
icon: priority.icon,
handler: this.onChangePriority,
}));
return [
{
id: 'assign_priority',
title: this.$t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
section: this.$t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_PRIORITY,
children: options.map(option => option.id),
},
...options,
];
},
assignTeamActions() {
const teamOptions = this.teamsList.map(team => ({
id: `team-${team.id}`,
title: team.name,
parent: 'assign_a_team',
section: this.$t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
teamInfo: team,
icon: ICON_ASSIGN_TEAM,
handler: this.onChangeTeam,
}));
return [
{
id: 'assign_a_team',
title: this.$t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
section: this.$t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ASSIGN_TEAM,
children: teamOptions.map(option => option.id),
},
...teamOptions,
];
},
addLabelActions() {
const availableLabels = this.inactiveLabels.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'add_a_label_to_the_conversation',
section: this.$t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
icon: ICON_ADD_LABEL,
handler: action => this.addLabelToConversation({ title: action.id }),
}));
return [
...availableLabels,
{
id: 'add_a_label_to_the_conversation',
title: this.$t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
section: this.$t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_ADD_LABEL,
children: this.inactiveLabels.map(label => label.title),
},
];
},
removeLabelActions() {
const activeLabels = this.activeLabels.map(label => ({
id: label.title,
title: `#${label.title}`,
parent: 'remove_a_label_to_the_conversation',
section: this.$t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
icon: ICON_REMOVE_LABEL,
handler: action => this.removeLabelFromConversation(action.id),
}));
return [
...activeLabels,
{
id: 'remove_a_label_to_the_conversation',
title: this.$t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
section: this.$t('COMMAND_BAR.SECTIONS.CONVERSATION'),
icon: ICON_REMOVE_LABEL,
children: this.activeLabels.map(label => label.title),
},
];
},
labelActions() {
if (this.activeLabels.length) {
return [...this.addLabelActions, ...this.removeLabelActions];
}
return this.addLabelActions;
},
conversationAdditionalActions() {
return this.prepareActions([
this.currentChat.muted ? UNMUTE_ACTION : MUTE_ACTION,
SEND_TRANSCRIPT_ACTION,
]);
},
nonDraftMessageAIAssistActions() {
if (this.replyMode === REPLY_EDITOR_MODES.REPLY) {
return [
{
label: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'
),
key: 'reply_suggestion',
icon: ICON_AI_ASSIST,
},
];
}
return [
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
key: 'summarize',
icon: ICON_AI_SUMMARY,
},
];
},
draftMessageAIAssistActions() {
return [
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
key: 'rephrase',
icon: ICON_AI_ASSIST,
},
{
label: this.$t(
'INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'
),
key: 'fix_spelling_grammar',
icon: ICON_AI_GRAMMAR,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
key: 'expand',
icon: ICON_AI_EXPAND,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
key: 'shorten',
icon: ICON_AI_SHORTEN,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'make_friendly',
icon: ICON_AI_ASSIST,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
key: 'make_formal',
icon: ICON_AI_ASSIST,
},
{
label: this.$t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
key: 'simplify',
icon: ICON_AI_ASSIST,
},
];
},
AIAssistActions() {
const aiOptions = this.draftMessage
? this.draftMessageAIAssistActions
: this.nonDraftMessageAIAssistActions;
const options = aiOptions.map(item => ({
id: `ai-assist-${item.key}`,
title: item.label,
parent: 'ai_assist',
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item,
icon: item.icon,
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
}));
return [
{
id: 'ai_assist',
title: this.$t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
icon: ICON_AI_ASSIST,
children: options.map(option => option.id),
},
...options,
];
},
isConversationOrInboxRoute() {
return (
isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name)
);
},
shouldShowSnoozeOption() {
return (
isAConversationRoute(this.$route.name, true, false) &&
this.contextMenuChatId
);
},
getDefaultConversationHotKeys() {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
},
conversationHotKeys() {
if (this.shouldShowSnoozeOption) {
return this.prepareActions(SNOOZE_CONVERSATION_ACTIONS);
}
if (this.isConversationOrInboxRoute) {
return this.getDefaultConversationHotKeys;
}
return [];
},
},
methods: {
onChangeAssignee(action) {
this.$store.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId: action.agentInfo.id,
});
},
onChangePriority(action) {
this.$store.dispatch('assignPriority', {
conversationId: this.currentChat.id,
priority: action.priority.key,
});
},
onChangeTeam(action) {
this.$store.dispatch('assignTeam', {
conversationId: this.currentChat.id,
teamId: action.teamInfo.id,
});
},
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -1,8 +1,3 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'dashboard/composables/route';
import { useAdmin } from 'dashboard/composables/useAdmin';
import {
ICON_ACCOUNT_SETTINGS,
ICON_AGENT_REPORTS,
@@ -19,9 +14,11 @@ import {
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from 'dashboard/helper/commandbar/icons';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
} from './CommandBarIcons';
import { frontendURL } from '../../../helper/URLHelper';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { FEATURE_FLAGS } from '../../../featureFlags';
const GO_TO_COMMANDS = [
{
@@ -175,45 +172,45 @@ const GO_TO_COMMANDS = [
},
];
export function useGoToCommandHotKeys() {
const { t } = useI18n();
const router = useRouter();
const { isAdmin } = useAdmin();
export default {
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
goToCommandHotKeys() {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return this.isFeatureEnabledonAccount(
this.accountId,
cmd.featureFlag
);
}
return true;
});
const currentAccountId = useMapGetter('getCurrentAccountId');
const isFeatureEnabledOnAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const openRoute = url => {
router.push(frontendURL(url));
};
const goToCommandHotKeys = computed(() => {
let commands = GO_TO_COMMANDS.filter(cmd => {
if (cmd.featureFlag) {
return isFeatureEnabledOnAccount.value(
currentAccountId.value,
cmd.featureFlag
);
if (!this.isAdmin) {
commands = commands.filter(command => command.role.includes('agent'));
}
return true;
});
if (!isAdmin.value) {
commands = commands.filter(command => command.role.includes('agent'));
}
return commands.map(command => ({
id: command.id,
section: t(command.section),
title: t(command.title),
icon: command.icon,
handler: () => openRoute(command.path(currentAccountId.value)),
}));
});
return {
goToCommandHotKeys,
};
}
return commands.map(command => ({
id: command.id,
section: this.$t(command.section),
title: this.$t(command.title),
icon: command.icon,
handler: () => this.openRoute(command.path(this.accountId)),
}));
},
},
methods: {
openRoute(url) {
this.$router.push(frontendURL(url));
},
},
};
@@ -1,19 +1,13 @@
import { computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
const createSnoozeHandler = option => () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, option);
const INBOX_SNOOZE_EVENTS = [
{
id: 'snooze_notification',
@@ -27,7 +21,8 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -35,7 +30,8 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -43,7 +39,8 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -51,7 +48,8 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
@@ -59,30 +57,26 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
handler: () =>
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export function useInboxHotKeys() {
const { t } = useI18n();
const route = useRoute();
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: action.section ? t(action.section) : undefined,
}));
};
const inboxHotKeys = computed(() => {
if (isAInboxViewRoute(route.name)) {
return prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
});
return {
inboxHotKeys,
};
}
export default {
computed: {
inboxHotKeys() {
if (isAInboxViewRoute(this.$route.name)) {
return this.prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
},
},
methods: {
prepareActions(actions) {
return actions.map(action => ({
...action,
title: this.$t(action.title),
section: this.$t(action.section),
}));
},
},
};
@@ -4,7 +4,7 @@ import FilterInputBox from '../../../../components/widgets/FilterInput/Index.vue
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from '../contactFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
@@ -13,6 +13,7 @@ export default {
components: {
FilterInputBox,
},
mixins: [filterMixin],
props: {
onClose: {
type: Function,
@@ -35,15 +36,6 @@ export default {
default: '',
},
},
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'CONTACTS_FILTER',
attributeModel: 'contact_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
@@ -77,10 +69,7 @@ export default {
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.setFilterAttributes();
if (this.getAppliedContactFilters.length) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else if (!this.isSegmentsView) {
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import SwitchLayout from './SwitchLayout.vue';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {
@@ -13,6 +14,7 @@ export default {
},
},
},
mixins: [messageFormatterMixin],
props: {
isOnExpandedLayout: {
type: Boolean,
@@ -2,7 +2,7 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { getUnixTime } from 'date-fns';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import wootConstants from 'dashboard/constants/globals';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
@@ -1,17 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import automationMethodsMixin from 'dashboard/mixins/automations/methodsMixin';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import { useAutomation } from 'dashboard/composables/useAutomation';
import { validateAutomation } from 'dashboard/helper/validations';
import {
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import {
AUTOMATION_RULE_EVENTS,
AUTOMATION_ACTION_TYPES,
@@ -22,38 +14,13 @@ export default {
FilterInputBox,
AutomationActionInput,
},
mixins: [automationMethodsMixin],
props: {
onClose: {
type: Function,
default: () => {},
},
},
setup() {
const {
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
} = useAutomation();
return {
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
};
},
data() {
return {
automationTypes: JSON.parse(JSON.stringify(AUTOMATIONS)),
@@ -115,24 +82,12 @@ export default {
this.$store.dispatch('labels/get');
this.$store.dispatch('campaigns/get');
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.manifestCustomAttributes(this.automationTypes);
this.manifestCustomAttributes();
},
methods: {
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
},
};
</script>
@@ -166,7 +121,7 @@ export default {
<select
v-model="automation.event_name"
class="m-0"
@change="onEventChange(automation)"
@change="onEventChange()"
>
<option
v-for="event in automationRuleEvents"
@@ -199,26 +154,9 @@ export default {
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:filter-attributes="getAttributes(automation.event_name)"
:input-type="getInputType(automation.conditions[i].attribute_key)"
:operators="getOperators(automation.conditions[i].attribute_key)"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
@@ -226,26 +164,15 @@ export default {
"
:show-query-operator="i !== automation.conditions.length - 1"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
getCustomAttributeType(automation.conditions[i].attribute_key)
"
:error-message="
errors[`condition_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@resetFilter="
resetFilter(
automation,
automationTypes,
i,
automation.conditions[i]
)
"
@removeFilter="removeFilter(automation, i)"
@resetFilter="resetFilter(i, automation.conditions[i])"
@removeFilter="removeFilter(i)"
/>
<div class="mt-4">
<woot-button
@@ -253,7 +180,7 @@ export default {
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewCondition(automation)"
@click="appendNewCondition"
>
{{ $t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL') }}
</woot-button>
@@ -278,18 +205,15 @@ export default {
getActionDropdownValues(automation.actions[i].action_name)
"
:show-action-input="
showActionInput(
automationActionTypes,
automation.actions[i].action_name
)
showActionInput(automation.actions[i].action_name)
"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
@resetAction="resetAction(automation, i)"
@removeAction="removeAction(automation, i)"
@resetAction="resetAction(i)"
@removeAction="removeAction(i)"
/>
<div class="mt-4">
<woot-button
@@ -297,7 +221,7 @@ export default {
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewAction(automation)"
@click="appendNewAction"
>
{{ $t('AUTOMATION.ADD.ACTION_BUTTON_LABEL') }}
</woot-button>
@@ -310,7 +234,7 @@ export default {
<woot-button class="button clear" @click.prevent="onClose">
{{ $t('AUTOMATION.ADD.CANCEL_BUTTON_TEXT') }}
</woot-button>
<woot-button @click="emitSaveAutomation">
<woot-button @click="submitAutomation">
{{ $t('AUTOMATION.ADD.SUBMIT') }}
</woot-button>
</div>
@@ -1,18 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAutomation } from 'dashboard/composables/useAutomation';
import automationMethodsMixin from 'dashboard/mixins/automations/methodsMixin';
import FilterInputBox from 'dashboard/components/widgets/FilterInput/Index.vue';
import AutomationActionInput from 'dashboard/components/widgets/AutomationActionInput.vue';
import {
getFileName,
generateAutomationPayload,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
} from 'dashboard/helper/automationHelper';
import { validateAutomation } from 'dashboard/helper/validations';
import {
AUTOMATION_RULE_EVENTS,
@@ -25,6 +15,7 @@ export default {
FilterInputBox,
AutomationActionInput,
},
mixins: [automationMethodsMixin],
props: {
onClose: {
type: Function,
@@ -35,34 +26,6 @@ export default {
default: () => {},
},
},
setup() {
const {
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
formatAutomation,
manifestCustomAttributes,
} = useAutomation();
return {
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
formatAutomation,
manifestCustomAttributes,
};
},
data() {
return {
automationTypes: JSON.parse(JSON.stringify(AUTOMATIONS)),
@@ -98,33 +61,14 @@ export default {
},
},
mounted() {
this.manifestCustomAttributes(this.automationTypes);
this.manifestCustomAttributes();
this.allCustomAttributes = this.$store.getters['attributes/getAttributes'];
this.automation = this.formatAutomation(
this.selectedResponse,
this.allCustomAttributes,
this.automationTypes,
this.automationActionTypes
);
this.formatAutomation(this.selectedResponse);
},
methods: {
getFileName,
getAttributes,
getInputType,
getOperators,
getCustomAttributeType,
showActionInput,
isFeatureEnabled(flag) {
return this.isFeatureEnabledonAccount(this.accountId, flag);
},
emitSaveAutomation() {
this.errors = validateAutomation(this.automation);
if (Object.keys(this.errors).length === 0) {
const automation = generateAutomationPayload(this.automation);
this.$emit('saveAutomation', automation, this.mode);
}
},
},
};
</script>
@@ -155,10 +99,7 @@ export default {
<div class="event_wrapper">
<label :class="{ error: errors.event_name }">
{{ $t('AUTOMATION.ADD.FORM.EVENT.LABEL') }}
<select
v-model="automation.event_name"
@change="onEventChange(automation)"
>
<select v-model="automation.event_name" @change="onEventChange()">
<option
v-for="event in automationRuleEvents"
:key="event.key"
@@ -184,37 +125,16 @@ export default {
v-for="(condition, i) in automation.conditions"
:key="i"
v-model="automation.conditions[i]"
:filter-attributes="
getAttributes(automationTypes, automation.event_name)
"
:input-type="
getInputType(
allCustomAttributes,
automationTypes,
automation,
automation.conditions[i].attribute_key
)
"
:operators="
getOperators(
allCustomAttributes,
automationTypes,
automation,
mode,
automation.conditions[i].attribute_key
)
"
:filter-attributes="getAttributes(automation.event_name)"
:input-type="getInputType(automation.conditions[i].attribute_key)"
:operators="getOperators(automation.conditions[i].attribute_key)"
:dropdown-values="
getConditionDropdownValues(
automation.conditions[i].attribute_key
)
"
:custom-attribute-type="
getCustomAttributeType(
automationTypes,
automation,
automation.conditions[i].attribute_key
)
getCustomAttributeType(automation.conditions[i].attribute_key)
"
:show-query-operator="i !== automation.conditions.length - 1"
:error-message="
@@ -222,15 +142,8 @@ export default {
? $t(`AUTOMATION.ERRORS.${errors[`condition_${i}`]}`)
: ''
"
@resetFilter="
resetFilter(
automation,
automationTypes,
i,
automation.conditions[i]
)
"
@removeFilter="removeFilter(automation, i)"
@resetFilter="resetFilter(i, automation.conditions[i])"
@removeFilter="removeFilter(i)"
/>
<div class="mt-4">
<woot-button
@@ -238,7 +151,7 @@ export default {
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewCondition(automation)"
@click="appendNewCondition"
>
{{ $t('AUTOMATION.ADD.CONDITION_BUTTON_LABEL') }}
</woot-button>
@@ -260,17 +173,15 @@ export default {
v-model="automation.actions[i]"
:action-types="automationActionTypes"
:dropdown-values="getActionDropdownValues(action.action_name)"
:show-action-input="
showActionInput(automationActionTypes, action.action_name)
"
:show-action-input="showActionInput(action.action_name)"
:error-message="
errors[`action_${i}`]
? $t(`AUTOMATION.ERRORS.${errors[`action_${i}`]}`)
: ''
"
:initial-file-name="getFileName(action, automation.files)"
@resetAction="resetAction(automation, i)"
@removeAction="removeAction(automation, i)"
@resetAction="resetAction(i)"
@removeAction="removeAction(i)"
/>
<div class="mt-4">
<woot-button
@@ -278,7 +189,7 @@ export default {
color-scheme="success"
variant="smooth"
size="small"
@click="appendNewAction(automation)"
@click="appendNewAction"
>
{{ $t('AUTOMATION.ADD.ACTION_BUTTON_LABEL') }}
</woot-button>
@@ -295,7 +206,7 @@ export default {
>
{{ $t('AUTOMATION.EDIT.CANCEL_BUTTON_TEXT') }}
</woot-button>
<woot-button @click="emitSaveAutomation">
<woot-button @click="submitAutomation">
{{ $t('AUTOMATION.EDIT.SUBMIT') }}
</woot-button>
</div>
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
@@ -7,12 +7,12 @@ import BillingItem from './components/BillingItem.vue';
export default {
components: { BillingItem },
mixins: [messageFormatterMixin],
setup() {
const { accountId } = useAccount();
const { formatMessage } = useMessageFormatter();
return {
accountId,
formatMessage,
};
},
computed: {
@@ -4,7 +4,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
import { CAMPAIGNS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -14,9 +14,9 @@ export default {
WootDateTimePicker,
WootMessageEditor,
},
mixins: [campaignMixin],
setup() {
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
@@ -9,16 +9,13 @@ export default {
CampaignsTable,
EditCampaign,
},
mixins: [campaignMixin],
props: {
type: {
type: String,
default: '',
},
},
setup() {
const { campaignType } = useCampaign();
return { campaignType };
},
data() {
return {
showEditPopup: false,
@@ -1,7 +1,7 @@
<script>
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { messageStamp } from 'shared/helpers/timeHelper';
export default {
@@ -9,6 +9,7 @@ export default {
UserAvatarWithName,
InboxName,
},
mixins: [messageFormatterMixin],
props: {
campaign: {
type: Object,
@@ -19,12 +20,7 @@ export default {
default: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
computed: {
campaignStatus() {
if (this.isOngoingType) {
@@ -1,7 +1,7 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignCard from './CampaignCard.vue';
export default {
@@ -10,6 +10,9 @@ export default {
Spinner,
CampaignCard,
},
mixins: [campaignMixin],
props: {
campaigns: {
type: Array,
@@ -24,10 +27,7 @@ export default {
default: false,
},
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
@@ -4,13 +4,14 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
mixins: [campaignMixin],
props: {
selectedCampaign: {
type: Object,
@@ -18,8 +19,7 @@ export default {
},
},
setup() {
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,5 +1,5 @@
<script>
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
@@ -8,10 +8,7 @@ export default {
Campaign,
AddCampaign,
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
mixins: [campaignMixin],
data() {
return { showAddPopup: false };
},
@@ -2,6 +2,7 @@
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -12,7 +13,7 @@ export default {
SelectChannelWarning,
SlackIntegrationHelpText,
},
mixins: [globalConfigMixin],
mixins: [globalConfigMixin, messageFormatterMixin],
props: {
code: { type: String, default: '' },
},
@@ -2,22 +2,16 @@
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
mixins: [globalConfigMixin],
mixins: [globalConfigMixin, messageFormatterMixin],
props: {
hasConnectedAChannel: {
type: Boolean,
default: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return { selectedChannelId: '', availableChannels: [] };
},
@@ -1,18 +1,13 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
mixins: [messageFormatterMixin],
props: {
selectedChannelName: {
type: String,
required: true,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
};
</script>
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import BotMetrics from './components/BotMetrics.vue';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import reportMixin from 'dashboard/mixins/reportMixin';
import ReportContainer from './ReportContainer.vue';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -13,6 +14,7 @@ export default {
ReportFilterSelector,
ReportContainer,
},
mixins: [reportMixin],
data() {
return {
from: 0,
@@ -4,6 +4,7 @@ import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import ReportFilterSelector from './components/FilterSelector.vue';
import { GROUP_BY_FILTER } from './constants';
import reportMixin from 'dashboard/mixins/reportMixin';
import { REPORTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import ReportContainer from './ReportContainer.vue';
@@ -23,6 +24,7 @@ export default {
ReportFilterSelector,
ReportContainer,
},
mixins: [reportMixin],
data() {
return {
from: 0,
@@ -1,23 +1,19 @@
<script>
import { mapGetters } from 'vuex';
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
import { GROUP_BY_FILTER, METRIC_CHART } from './constants';
import fromUnixTime from 'date-fns/fromUnixTime';
import format from 'date-fns/format';
import { formatTime } from '@chatwoot/utils';
import reportMixin from 'dashboard/mixins/reportMixin';
import ChartStats from './components/ChartElements/ChartStats.vue';
export default {
components: { ChartStats },
mixins: [reportMixin],
props: {
groupBy: {
type: Object,
default: () => ({}),
},
accountSummaryKey: {
type: String,
default: 'getAccountSummary',
},
reportKeys: {
type: Object,
default: () => ({
@@ -31,16 +27,7 @@ export default {
}),
},
},
setup(props) {
const { calculateTrend, isAverageMetricType } = useReportMetrics(
props.accountSummaryKey
);
return { calculateTrend, isAverageMetricType };
},
computed: {
...mapGetters({
accountReport: 'getAccountReports',
}),
metrics() {
const reportKeys = Object.keys(this.reportKeys);
const infoText = {
@@ -134,12 +121,12 @@ export default {
<template>
<div
class="grid grid-cols-1 p-2 bg-white border rounded-md md:grid-cols-2 lg:grid-cols-3 dark:bg-slate-800 border-slate-100 dark:border-slate-700"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 bg-white dark:bg-slate-800 p-2 border border-slate-100 dark:border-slate-700 rounded-md"
>
<div
v-for="metric in metrics"
:key="metric.KEY"
class="p-4 mb-3 rounded-md"
class="p-4 rounded-md mb-3"
>
<ChartStats :metric="metric" :account-summary-key="accountSummaryKey" />
<div class="mt-4 h-72">
@@ -148,12 +135,12 @@ export default {
class="text-xs"
:message="$t('REPORT.LOADING_CHART')"
/>
<div v-else class="flex items-center justify-center h-72">
<div v-else class="h-72 flex items-center justify-center">
<woot-bar
v-if="accountReport.data[metric.KEY].length"
:collection="getCollection(metric)"
:chart-options="getChartOptions(metric)"
class="w-full h-72"
class="h-72 w-full"
/>
<span v-else class="text-sm text-slate-600">
{{ $t('REPORT.NO_ENOUGH_DATA') }}
@@ -1,30 +1,25 @@
<script setup>
import { useReportMetrics } from 'dashboard/composables/useReportMetrics';
const props = defineProps({
metric: {
type: Object,
default: () => ({}),
<script>
import reportMixin from 'dashboard/mixins/reportMixin';
export default {
mixins: [reportMixin],
props: {
metric: {
type: Object,
default: () => ({}),
},
},
accountSummaryKey: {
type: String,
default: 'getAccountSummary',
methods: {
trendColor(value, key) {
if (this.isAverageMetricType(key)) {
return value > 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
}
return value < 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
},
},
});
const { calculateTrend, displayMetric, isAverageMetricType } = useReportMetrics(
props.accountSummaryKey
);
const trendColor = (value, key) => {
if (isAverageMetricType(key)) {
return value > 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
}
return value < 0
? 'border-red-500 text-red-500'
: 'border-green-500 text-green-500';
};
</script>
@@ -34,7 +29,7 @@ const trendColor = (value, key) => {
{{ metric.NAME }}
</span>
<div class="flex items-end">
<div class="text-xl font-medium">
<div class="font-medium text-xl">
{{ displayMetric(metric.KEY) }}
</div>
<div v-if="metric.trend" class="text-xs ml-4 flex items-center mb-0.5">
@@ -3,6 +3,7 @@ import { useAlert } from 'dashboard/composables';
import ReportFilters from './ReportFilters.vue';
import ReportContainer from '../ReportContainer.vue';
import { GROUP_BY_FILTER } from '../constants';
import reportMixin from '../../../../../mixins/reportMixin';
import { generateFileName } from '../../../../../helper/downloadHelper';
import { REPORTS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
@@ -21,6 +22,7 @@ export default {
ReportFilters,
ReportContainer,
},
mixins: [reportMixin],
props: {
type: {
type: String,
@@ -1,9 +1,10 @@
<script>
import { ref, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
mixins: [messageFormatterMixin],
props: {
items: {
type: Array,
@@ -29,7 +30,7 @@ export default {
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
const { highlightContent } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
@@ -52,7 +53,6 @@ export default {
selectedIndex,
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
};
},
@@ -1,11 +1,12 @@
<script>
import ChatOption from 'shared/components/ChatOption.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
export default {
components: {
ChatOption,
},
mixins: [messageFormatterMixin],
props: {
title: {
type: String,
@@ -24,12 +25,6 @@ export default {
default: false,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
methods: {
isSelected(option) {
return this.selected === option.id;
@@ -1,47 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { useCampaign } from '../useCampaign';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../../constants/campaign';
// Mock the useRoute composable
vi.mock('dashboard/composables/route', () => ({
useRoute: vi.fn(),
}));
describe('useCampaign', () => {
it('returns the correct campaign type for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONGOING);
});
it('returns the correct campaign type for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONE_OFF);
});
it('isOngoingType returns true for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(true);
});
it('isOngoingType returns false for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(false);
});
it('isOneOffType returns true for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(true);
});
it('isOneOffType returns false for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(false);
});
});
@@ -1,145 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useFilter } from '../useFilter';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
// Mock the dependencies
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
describe('useFilter', () => {
// Setup mocks
beforeEach(() => {
vi.mocked(useStore).mockReturnValue({
getters: {
'attributes/getAttributesByModel': vi.fn(),
},
});
vi.mocked(useI18n).mockReturnValue({
t: vi.fn(key => key),
});
});
it('should return the correct functions', () => {
const {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({ filteri18nKey: 'TEST', attributeModel: 'conversation' });
expect(setFilterAttributes).toBeDefined();
expect(initializeStatusAndAssigneeFilterToModal).toBeDefined();
expect(initializeInboxTeamAndLabelFilterToModal).toBeDefined();
});
describe('setFilterAttributes', () => {
it('should return filterGroups and filterTypes', () => {
const mockAttributes = [
{
attribute_key: 'test_key',
attribute_display_name: 'Test Name',
attribute_display_type: 'text',
},
];
vi.mocked(useStore)().getters[
'attributes/getAttributesByModel'
].mockReturnValue(mockAttributes);
const { setFilterAttributes } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = setFilterAttributes();
expect(result).toHaveProperty('filterGroups');
expect(result).toHaveProperty('filterTypes');
expect(result.filterGroups.length).toBeGreaterThan(0);
expect(result.filterTypes.length).toBeGreaterThan(0);
});
});
describe('initializeStatusAndAssigneeFilterToModal', () => {
it('should return status filter when activeStatus is provided', () => {
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeStatusAndAssigneeFilterToModal('open', {}, '');
expect(result).toEqual({
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{ id: 'open', name: 'CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT' },
],
query_operator: 'and',
custom_attribute_type: '',
});
});
it('should return null when no active filters', () => {
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeStatusAndAssigneeFilterToModal('', {}, '');
expect(result).toBeNull();
});
});
describe('initializeInboxTeamAndLabelFilterToModal', () => {
it('should return filters for inbox, team, and label when provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
1,
{ name: 'Inbox 1' },
2,
[{ id: 2, name: 'Team 2' }],
'Label 1'
);
expect(result).toHaveLength(3);
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
expect(result[1]).toHaveProperty('attribute_key', 'team_id');
expect(result[2]).toHaveProperty('attribute_key', 'labels');
});
it('should return empty array when no filters are provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
null,
null,
null,
null,
null
);
expect(result).toEqual([]);
});
it('should return only inbox filter when only inbox is provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
1,
{ name: 'Inbox 1' },
null,
null,
null
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
});
});
});
@@ -1,79 +0,0 @@
import { useMessageFormatter } from '../useMessageFormatter';
describe('useMessageFormatter', () => {
let messageFormatter;
beforeEach(() => {
messageFormatter = useMessageFormatter();
});
describe('formatMessage', () => {
it('should format a regular message correctly', () => {
const message = 'This is a [test](https://example.com) message';
const result = messageFormatter.formatMessage(message, false, false);
expect(result).toContain('<a href="https://example.com"');
expect(result).toContain('class="link"');
});
it('should format a tweet correctly', () => {
const message = '@user #hashtag';
const result = messageFormatter.formatMessage(message, true, false);
expect(result).toContain('<a href="http://twitter.com/user"');
expect(result).toContain('<a href="https://twitter.com/hashtag/hashtag"');
});
it('should not format mentions and hashtags for private notes', () => {
const message = '@user #hashtag';
const result = messageFormatter.formatMessage(message, false, true);
expect(result).not.toContain('<a href="http://twitter.com/user"');
expect(result).not.toContain(
'<a href="https://twitter.com/hashtag/hashtag"'
);
});
});
describe('truncateMessage', () => {
it('should not truncate short messages', () => {
const message = 'Short message';
const result = messageFormatter.truncateMessage(message);
expect(result).toBe(message);
});
it('should truncate long messages', () => {
const message = 'A'.repeat(150);
const result = messageFormatter.truncateMessage(message);
expect(result.length).toBe(100);
expect(result.endsWith('...')).toBe(true);
});
});
describe('highlightContent', () => {
it('should highlight search term in content', () => {
const content = 'This is a test message';
const searchTerm = 'test';
const highlightClass = 'highlight';
const result = messageFormatter.highlightContent(
content,
searchTerm,
highlightClass
);
expect(result.trim()).toBe(
'This is a <span class="highlight">test</span> message'
);
});
it('should handle special characters in search term', () => {
const content = 'This (message) contains [special] characters';
const searchTerm = '(message)';
const highlightClass = 'highlight';
const result = messageFormatter.highlightContent(
content,
searchTerm,
highlightClass
);
expect(result.trim()).toBe(
'This <span class="highlight">(message)</span> contains [special] characters'
);
});
});
});
@@ -1,43 +0,0 @@
import { computed } from 'vue';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../constants/campaign';
/**
* Composable to manage campaign types.
*
* @returns {Object} - Computed properties for campaign type and its checks.
*/
export const useCampaign = () => {
const route = useRoute();
/**
* Computed property to determine the current campaign type based on the route name.
*/
const campaignType = computed(() => {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[route.name];
});
/**
* Computed property to check if the current campaign type is 'ongoing'.
*/
const isOngoingType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONGOING;
});
/**
* Computed property to check if the current campaign type is 'one-off'.
*/
const isOneOffType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONE_OFF;
});
return {
campaignType,
isOngoingType,
isOneOffType,
};
};
@@ -1,175 +0,0 @@
import wootConstants from 'dashboard/constants/globals';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { filterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const customAttributeInputType = key => {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
};
const getOperatorTypes = key => {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
};
export const useFilter = ({ filteri18nKey, attributeModel }) => {
const { t: $t } = useI18n();
const { getters } = useStore();
const setFilterAttributes = () => {
const allCustomAttributes =
getters['attributes/getAttributesByModel'](attributeModel);
const customAttributesFormatted = {
name: $t(`${filteri18nKey}.GROUPS.CUSTOM_ATTRIBUTES`),
attributes: allCustomAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
};
}),
};
const allFilterGroups = filterAttributeGroups.map(group => {
return {
name: $t(`${filteri18nKey}.GROUPS.${group.i18nGroup}`),
attributes: group.attributes.map(attribute => {
return {
key: attribute.key,
name: $t(`${filteri18nKey}.ATTRIBUTES.${attribute.i18nKey}`),
};
}),
};
});
const customAttributeTypes = allCustomAttributes.map(attr => {
return {
attributeKey: attr.attribute_key,
attributeI18nKey: `CUSTOM_ATTRIBUTE_${attr.attribute_display_type.toUpperCase()}`,
inputType: customAttributeInputType(attr.attribute_display_type),
filterOperators: getOperatorTypes(attr.attribute_display_type),
attributeModel: 'custom_attributes',
};
});
return {
filterGroups: [...allFilterGroups, customAttributesFormatted],
filterTypes: [...customAttributeTypes],
};
};
const initializeStatusAndAssigneeFilterToModal = (
activeStatus,
currentUserDetails,
activeAssigneeTab
) => {
if (activeStatus !== '') {
return {
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: activeStatus,
name: $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`),
},
],
query_operator: 'and',
custom_attribute_type: '',
};
}
if (activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ME) {
return {
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: currentUserDetails,
query_operator: 'and',
custom_attribute_type: '',
};
}
return null;
};
const initializeInboxTeamAndLabelFilterToModal = (
conversationInbox,
inbox,
teamId,
activeTeam,
label
) => {
const filters = [];
if (conversationInbox) {
filters.push({
attribute_key: 'inbox_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: conversationInbox,
name: inbox.name,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (teamId) {
filters.push({
attribute_key: 'team_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: activeTeam,
query_operator: 'and',
custom_attribute_type: '',
});
}
if (label) {
filters.push({
attribute_key: 'labels',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: label,
name: label,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
return filters;
};
return {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
};
};
@@ -1,82 +0,0 @@
import MessageFormatter from '../helpers/MessageFormatter';
/**
* A composable providing utility functions for message formatting.
*
* @returns {Object} A set of functions for message formatting.
*/
export const useMessageFormatter = () => {
/**
* Formats a message based on specified conditions.
*
* @param {string} message - The message to be formatted.
* @param {boolean} isATweet - Whether the message is a tweet.
* @param {boolean} isAPrivateNote - Whether the message is a private note.
* @returns {string} - The formatted message.
*/
const formatMessage = (message, isATweet, isAPrivateNote) => {
const messageFormatter = new MessageFormatter(
message,
isATweet,
isAPrivateNote
);
return messageFormatter.formattedMessage;
};
/**
* Converts a message to plain text.
*
* @param {string} message - The message to be converted.
* @param {boolean} isATweet - Whether the message is a tweet.
* @returns {string} - The plain text message.
*/
const getPlainText = (message, isATweet) => {
const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.plainText;
};
/**
* Truncates a description to a maximum length of 100 characters.
*
* @param {string} [description=''] - The description to be truncated.
* @returns {string} - The truncated description.
*/
const truncateMessage = (description = '') => {
if (description.length < 100) {
return description;
}
return `${description.slice(0, 97)}...`;
};
/**
* Highlights occurrences of a search term within given content.
*
* @param {string} [content=''] - The content in which to search.
* @param {string} [searchTerm=''] - The term to search for.
* @param {string} [highlightClass=''] - The CSS class to apply to the highlighted term.
* @returns {string} - The content with highlighted terms.
*/
const highlightContent = (
content = '',
searchTerm = '',
highlightClass = ''
) => {
const plainTextContent = getPlainText(content);
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return plainTextContent.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
`<span class="${highlightClass}">$1</span>`
);
};
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
};
};
@@ -0,0 +1,19 @@
import { CAMPAIGN_TYPES } from '../constants/campaign';
export default {
computed: {
campaignType() {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[this.$route.name];
},
isOngoingType() {
return this.campaignType === CAMPAIGN_TYPES.ONGOING;
},
isOneOffType() {
return this.campaignType === CAMPAIGN_TYPES.ONE_OFF;
},
},
};
+119
View File
@@ -0,0 +1,119 @@
import wootConstants from 'dashboard/constants/globals';
export default {
methods: {
setFilterAttributes() {
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const customAttributesFormatted = {
name: this.$t(`${this.filtersFori18n}.GROUPS.CUSTOM_ATTRIBUTES`),
attributes: allCustomAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
};
}),
};
const allFilterGroups = this.filterAttributeGroups.map(group => {
return {
name: this.$t(`${this.filtersFori18n}.GROUPS.${group.i18nGroup}`),
attributes: group.attributes.map(attribute => {
return {
key: attribute.key,
name: this.$t(
`${this.filtersFori18n}.ATTRIBUTES.${attribute.i18nKey}`
),
};
}),
};
});
const customAttributeTypes = allCustomAttributes.map(attr => {
return {
attributeKey: attr.attribute_key,
attributeI18nKey: `CUSTOM_ATTRIBUTE_${attr.attribute_display_type.toUpperCase()}`,
inputType: this.customAttributeInputType(attr.attribute_display_type),
filterOperators: this.getOperatorTypes(attr.attribute_display_type),
attributeModel: 'custom_attributes',
};
});
this.filterTypes = [...this.filterTypes, ...customAttributeTypes];
this.filterGroups = [...allFilterGroups, customAttributesFormatted];
},
initializeExistingFilterToModal() {
this.initializeStatusAndAssigneeFilterToModal();
this.initializeInboxTeamAndLabelFilterToModal();
},
initializeStatusAndAssigneeFilterToModal() {
if (this.activeStatus !== '') {
this.appliedFilter.push({
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.activeStatus,
name: this.$t(
`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${this.activeStatus}.TEXT`
),
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ME) {
this.appliedFilter.push({
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: this.currentUserDetails,
query_operator: 'and',
custom_attribute_type: '',
});
}
},
initializeInboxTeamAndLabelFilterToModal() {
if (this.conversationInbox) {
this.appliedFilter.push({
attribute_key: 'inbox_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.conversationInbox,
name: this.inbox.name,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.teamId) {
this.appliedFilter.push({
attribute_key: 'team_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: this.activeTeam,
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.label) {
this.appliedFilter.push({
attribute_key: 'labels',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.label,
name: this.label,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
},
},
};
@@ -0,0 +1,40 @@
import MessageFormatter from '../helpers/MessageFormatter';
export default {
methods: {
formatMessage(message, isATweet, isAPrivateNote) {
const messageFormatter = new MessageFormatter(
message,
isATweet,
isAPrivateNote
);
return messageFormatter.formattedMessage;
},
getPlainText(message, isATweet) {
const messageFormatter = new MessageFormatter(message, isATweet);
return messageFormatter.plainText;
},
truncateMessage(description = '') {
if (description.length < 100) {
return description;
}
return `${description.slice(0, 97)}...`;
},
highlightContent(content = '', searchTerm = '', highlightClass = '') {
const plainTextContent = this.getPlainText(content);
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
const escapedSearchTerm = searchTerm.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&'
);
return plainTextContent.replace(
new RegExp(`(${escapedSearchTerm})`, 'ig'),
`<span class="${highlightClass}">$1</span>`
);
},
},
};
@@ -1,6 +1,6 @@
import allLanguages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import allLanguages from '../../../dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
import allCountries from 'shared/constants/countries.js';
import allCountries from '../../../shared/constants/countries.js';
export const customAttributes = [
{
@@ -11,11 +11,11 @@ import {
contactAttrs,
conversationAttrs,
expectedOutputForCustomAttributeGenerator,
} from './fixtures/automationFixtures';
} from './automationFixtures';
import { AUTOMATIONS } from 'dashboard/routes/dashboard/settings/automation/constants';
describe('getCustomAttributeInputType', () => {
it('returns the attribute input type', () => {
describe('automationMethodsMixin', () => {
it('getCustomAttributeInputType returns the attribute input type', () => {
expect(helpers.getCustomAttributeInputType('date')).toEqual('date');
expect(helpers.getCustomAttributeInputType('date')).not.toEqual(
'some_random_value'
@@ -31,32 +31,33 @@ describe('getCustomAttributeInputType', () => {
'plain_text'
);
});
});
describe('isACustomAttribute', () => {
it('returns the custom attribute value if true', () => {
it('isACustomAttribute returns the custom attribute value if true', () => {
expect(
helpers.isACustomAttribute(customAttributes, 'signed_up_at')
).toBeTruthy();
expect(helpers.isACustomAttribute(customAttributes, 'status')).toBeFalsy();
});
});
describe('getCustomAttributeListDropdownValues', () => {
it('returns the attribute dropdown values', () => {
it('getCustomAttributeListDropdownValues returns the attribute dropdown values', () => {
const myListValues = [
{ id: 'item1', name: 'item1' },
{ id: 'item2', name: 'item2' },
{ id: 'item3', name: 'item3' },
{
id: 'item1',
name: 'item1',
},
{
id: 'item2',
name: 'item2',
},
{
id: 'item3',
name: 'item3',
},
];
expect(
helpers.getCustomAttributeListDropdownValues(customAttributes, 'my_list')
).toEqual(myListValues);
});
});
describe('isCustomAttributeCheckbox', () => {
it('checks if attribute is a checkbox', () => {
it('isCustomAttributeCheckbox checks if attribute is a checkbox', () => {
expect(
helpers.isCustomAttributeCheckbox(customAttributes, 'prime_user')
.attribute_display_type
@@ -69,19 +70,13 @@ describe('isCustomAttributeCheckbox', () => {
helpers.isCustomAttributeCheckbox(customAttributes, 'my_list')
).not.toEqual('checkbox');
});
});
describe('isCustomAttributeList', () => {
it('checks if attribute is a list', () => {
it('isCustomAttributeList checks if attribute is a list', () => {
expect(
helpers.isCustomAttributeList(customAttributes, 'my_list')
.attribute_display_type
).toEqual('list');
});
});
describe('getOperatorTypes', () => {
it('returns the correct custom attribute operators', () => {
it('getOperatorTypes returns the correct custom attribute operators', () => {
expect(helpers.getOperatorTypes('list')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('text')).toEqual(OPERATOR_TYPES_3);
expect(helpers.getOperatorTypes('number')).toEqual(OPERATOR_TYPES_1);
@@ -90,44 +85,93 @@ describe('getOperatorTypes', () => {
expect(helpers.getOperatorTypes('checkbox')).toEqual(OPERATOR_TYPES_1);
expect(helpers.getOperatorTypes('some_random')).toEqual(OPERATOR_TYPES_1);
});
});
describe('generateConditionOptions', () => {
it('returns expected conditions options array', () => {
it('generateConditionOptions returns expected conditions options array', () => {
const testConditions = [
{ id: 123, title: 'Fayaz', email: 'test@test.com' },
{ title: 'John', id: 324, email: 'test@john.com' },
{
id: 123,
title: 'Fayaz',
email: 'test@test.com',
},
{
title: 'John',
id: 324,
email: 'test@john.com',
},
];
const expectedConditions = [
{ id: 123, name: 'Fayaz' },
{ id: 324, name: 'John' },
{
id: 123,
name: 'Fayaz',
},
{
id: 324,
name: 'John',
},
];
expect(helpers.generateConditionOptions(testConditions)).toEqual(
expectedConditions
);
});
});
describe('getActionOptions', () => {
it('returns expected actions options array', () => {
it('getActionOptions returns expected actions options array', () => {
const expectedOptions = [
{ id: 'testlabel', name: 'testlabel' },
{ id: 'snoozes', name: 'snoozes' },
{
id: 'testlabel',
name: 'testlabel',
},
{
id: 'snoozes',
name: 'snoozes',
},
];
expect(helpers.getActionOptions({ labels, type: 'add_label' })).toEqual(
expectedOptions
);
});
});
describe('getConditionOptions', () => {
it('returns expected conditions options', () => {
it('getConditionOptions returns expected conditions options', () => {
const testOptions = [
{ id: 'open', name: 'Open' },
{ id: 'resolved', name: 'Resolved' },
{ id: 'pending', name: 'Pending' },
{ id: 'snoozed', name: 'Snoozed' },
{ id: 'all', name: 'All' },
{
id: 'open',
name: 'Open',
},
{
id: 'resolved',
name: 'Resolved',
},
{
id: 'pending',
name: 'Pending',
},
{
id: 'snoozed',
name: 'Snoozed',
},
{
id: 'all',
name: 'All',
},
];
const expectedOptions = [
{
id: 'open',
name: 'Open',
},
{
id: 'resolved',
name: 'Resolved',
},
{
id: 'pending',
name: 'Pending',
},
{
id: 'snoozed',
name: 'Snoozed',
},
{
id: 'all',
name: 'All',
},
];
expect(
helpers.getConditionOptions({
@@ -136,20 +180,14 @@ describe('getConditionOptions', () => {
statusFilterOptions: testOptions,
type: 'status',
})
).toEqual(testOptions);
).toEqual(expectedOptions);
});
});
describe('getFileName', () => {
it('returns the correct file name', () => {
it('getFileName returns the correct file name', () => {
expect(
helpers.getFileName(automation.actions[0], automation.files)
).toEqual('pfp.jpeg');
});
});
describe('getDefaultConditions', () => {
it('returns the resp default condition model', () => {
it('getDefaultConditions returns the resp default condition model', () => {
const messageCreatedModel = [
{
attribute_key: 'message_type',
@@ -173,10 +211,7 @@ describe('getDefaultConditions', () => {
);
expect(helpers.getDefaultConditions()).toEqual(genericConditionModel);
});
});
describe('getDefaultActions', () => {
it('returns the resp default action model', () => {
it('getDefaultActions returns the resp default action model', () => {
const genericActionModel = [
{
action_name: 'assign_agent',
@@ -185,10 +220,7 @@ describe('getDefaultActions', () => {
];
expect(helpers.getDefaultActions()).toEqual(genericActionModel);
});
});
describe('filterCustomAttributes', () => {
it('filters the raw custom attributes', () => {
it('filterCustomAttributes filters the raw custom attributes', () => {
const filteredAttributes = [
{ key: 'signed_up_at', name: 'Signed Up At', type: 'date' },
{ key: 'prime_user', name: 'Prime User', type: 'checkbox' },
@@ -203,10 +235,7 @@ describe('filterCustomAttributes', () => {
filteredAttributes
);
});
});
describe('getStandardAttributeInputType', () => {
it('returns the resp default action model', () => {
it('getStandardAttributeInputType returns the resp default action model', () => {
expect(
helpers.getStandardAttributeInputType(
AUTOMATIONS,
@@ -229,10 +258,7 @@ describe('getStandardAttributeInputType', () => {
)
).toEqual('plain_text');
});
});
describe('generateAutomationPayload', () => {
it('returns the resp default action model', () => {
it('generateAutomationPayload returns the resp default action model', () => {
const testPayload = {
name: 'Test',
description: 'This is a test',
@@ -274,10 +300,7 @@ describe('generateAutomationPayload', () => {
expectedPayload
);
});
});
describe('isCustomAttribute', () => {
it('returns the resp default action model', () => {
it('isCustomAttribute returns the resp default action model', () => {
const attrs = helpers.filterCustomAttributes(customAttributes);
expect(helpers.isCustomAttribute(attrs, 'my_list')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'my_check')).toBeTruthy();
@@ -286,10 +309,8 @@ describe('isCustomAttribute', () => {
expect(helpers.isCustomAttribute(attrs, 'prime_user')).toBeTruthy();
expect(helpers.isCustomAttribute(attrs, 'hello')).toBeFalsy();
});
});
describe('generateCustomAttributes', () => {
it('generates and returns correct condition attribute', () => {
it('generateCustomAttributes generates and returns correct condition attribute', () => {
expect(
helpers.generateCustomAttributes(
conversationAttrs,
@@ -300,116 +321,3 @@ describe('generateCustomAttributes', () => {
).toEqual(expectedOutputForCustomAttributeGenerator);
});
});
describe('getAttributes', () => {
it('returns the conditions for the given automation type', () => {
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
});
});
describe('getAttributes', () => {
it('returns the conditions for the given automation type', () => {
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
});
});
describe('getAutomationType', () => {
it('returns the automation type for the given key', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getAutomationType(
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
);
});
});
describe('getInputType', () => {
it('returns the input type for a custom attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getInputType(
customAttributes,
AUTOMATIONS,
mockAutomation,
'signed_up_at'
);
expect(result).toEqual('date');
});
it('returns the input type for a standard attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getInputType(
customAttributes,
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual('search_select');
});
});
describe('getOperators', () => {
it('returns operators for a custom attribute in edit mode', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getOperators(
customAttributes,
AUTOMATIONS,
mockAutomation,
'edit',
'signed_up_at'
);
expect(result).toEqual(OPERATOR_TYPES_4);
});
it('returns operators for a standard attribute', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getOperators(
customAttributes,
AUTOMATIONS,
mockAutomation,
'create',
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
.filterOperators
);
});
});
describe('getCustomAttributeType', () => {
it('returns the custom attribute type for the given key', () => {
const mockAutomation = { event_name: 'message_created' };
const result = helpers.getCustomAttributeType(
AUTOMATIONS,
mockAutomation,
'message_type'
);
expect(result).toEqual(
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
.customAttributeType
);
});
});
describe('showActionInput', () => {
it('returns false for send_email_to_team and send_message actions', () => {
expect(helpers.showActionInput([], 'send_email_to_team')).toBe(false);
expect(helpers.showActionInput([], 'send_message')).toBe(false);
});
it('returns true if the action has an input type', () => {
const mockActionTypes = [{ key: 'add_label', inputType: 'select' }];
expect(helpers.showActionInput(mockActionTypes, 'add_label')).toBe(true);
});
it('returns false if the action does not have an input type', () => {
const mockActionTypes = [{ key: 'some_action', inputType: null }];
expect(helpers.showActionInput(mockActionTypes, 'some_action')).toBe(false);
});
});
@@ -0,0 +1,450 @@
import methodsMixin from '../../../dashboard/mixins/automations/methodsMixin';
import {
automation,
customAttributes,
agents,
booleanFilterOptions,
teams,
labels,
statusFilterOptions,
campaigns,
contacts,
inboxes,
languages,
countries,
slaPolicies,
MESSAGE_CONDITION_VALUES,
automationToSubmit,
savedAutomation,
} from './automationFixtures';
import {
AUTOMATIONS,
AUTOMATION_ACTION_TYPES,
} from '../../../dashboard/routes/dashboard/settings/automation/constants.js';
import { createWrapper, createLocalVue } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
// Vuelidate required to test submit method
const createComponent = (
mixins,
data,
// eslint-disable-next-line default-param-last
computed = {},
// eslint-disable-next-line default-param-last
methods = {},
validations
) => {
const Component = {
render() {},
mixins,
data,
computed,
methods,
validations,
};
const Constructor = Vue.extend(Component);
const vm = new Constructor().$mount();
return createWrapper(vm);
};
const generateComputedProperties = () => {
return {
statusFilterOptions() {
return statusFilterOptions;
},
agents() {
return agents;
},
customAttributes() {
return customAttributes;
},
labels() {
return labels;
},
teams() {
return teams;
},
booleanFilterOptions() {
return booleanFilterOptions;
},
campaigns() {
return campaigns;
},
contacts() {
return contacts;
},
inboxes() {
return inboxes;
},
languages() {
return languages;
},
countries() {
return countries;
},
slaPolicies() {
return slaPolicies;
},
MESSAGE_CONDITION_VALUES() {
return MESSAGE_CONDITION_VALUES;
},
};
};
describe('automationMethodsMixin', () => {
it('getFileName returns the correct file name', () => {
const data = () => {
return {};
};
const wrapper = createComponent([methodsMixin], data);
expect(
wrapper.vm.getFileName(automation.actions[0], automation.files)
).toEqual(automation.files[0].filename);
});
it('getAttributes returns all attributes', () => {
const data = () => {
return {
automationTypes: AUTOMATIONS,
};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.getAttributes('conversation_created')).toEqual(
AUTOMATIONS.conversation_created.conditions
);
});
it('getAttributes returns all respective attributes', () => {
const data = () => {
return {
allCustomAttributes: customAttributes,
automationTypes: AUTOMATIONS,
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.getInputType('status')).toEqual('multi_select');
expect(wrapper.vm.getInputType('my_list')).toEqual('search_select');
});
it('getOperators returns all respective operators', () => {
const data = () => {
return {
allCustomAttributes: customAttributes,
automationTypes: AUTOMATIONS,
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.getOperators('status')).toEqual(
AUTOMATIONS.conversation_created.conditions[0].filterOperators
);
});
it('getAutomationType returns the correct automationType', () => {
const data = () => {
return {
automationTypes: AUTOMATIONS,
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.getAutomationType('status')).toEqual(
AUTOMATIONS[automation.event_name].conditions[0]
);
});
it('getConditionDropdownValues returns respective condition dropdown values', () => {
const computed = generateComputedProperties();
const data = () => {
return {
allCustomAttributes: customAttributes,
};
};
const wrapper = createComponent([methodsMixin], data, computed);
expect(wrapper.vm.getConditionDropdownValues('status')).toEqual(
statusFilterOptions
);
expect(wrapper.vm.getConditionDropdownValues('team_id')).toEqual(teams);
expect(wrapper.vm.getConditionDropdownValues('assignee_id')).toEqual(
agents
);
expect(wrapper.vm.getConditionDropdownValues('contact')).toEqual(contacts);
expect(wrapper.vm.getConditionDropdownValues('inbox_id')).toEqual(inboxes);
expect(wrapper.vm.getConditionDropdownValues('campaigns')).toEqual(
campaigns
);
expect(wrapper.vm.getConditionDropdownValues('browser_language')).toEqual(
languages
);
expect(wrapper.vm.getConditionDropdownValues('country_code')).toEqual(
countries
);
expect(wrapper.vm.getConditionDropdownValues('message_type')).toEqual(
MESSAGE_CONDITION_VALUES
);
});
it('appendNewCondition appends a new condition to the automation data property', () => {
const condition = {
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
};
const data = () => {
return {
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.appendNewCondition();
expect(automation.conditions[automation.conditions.length - 1]).toEqual(
condition
);
});
it('appendNewAction appends a new condition to the automation data property', () => {
const action = {
action_name: 'assign_agent',
action_params: [],
};
const data = () => {
return {
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.appendNewAction();
expect(automation.actions[automation.actions.length - 1]).toEqual(action);
});
it('removeFilter removes the given condition in the automation', () => {
const data = () => {
return {
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.removeFilter(0);
expect(automation.conditions.length).toEqual(1);
});
it('removeAction removes the given action in the automation', () => {
const data = () => {
return {
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.removeAction(0);
expect(automation.actions.length).toEqual(1);
});
it('resetFilter resets the current automation conditions', () => {
const data = () => {
return {
automation: automationToSubmit,
automationTypes: AUTOMATIONS,
};
};
const conditionAfterReset = {
attribute_key: 'status',
filter_operator: 'equal_to',
values: '',
query_operator: 'and',
custom_attribute_type: '',
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.resetFilter(0, automationToSubmit.conditions[0]);
expect(automation.conditions[0]).toEqual(conditionAfterReset);
});
it('showUserInput returns boolean value based on the operator type', () => {
const data = () => {
return {};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.showUserInput('is_present')).toBeFalsy();
expect(wrapper.vm.showUserInput('is_not_present')).toBeFalsy();
expect(wrapper.vm.showUserInput('equal_to')).toBeTruthy();
expect(wrapper.vm.showUserInput('not_equal_to')).toBeTruthy();
});
it('showActionInput returns boolean value based on the action type', () => {
const data = () => {
return {
automationActionTypes: AUTOMATION_ACTION_TYPES,
};
};
const wrapper = createComponent([methodsMixin], data);
expect(wrapper.vm.showActionInput('send_email_to_team')).toBeFalsy();
expect(wrapper.vm.showActionInput('send_message')).toBeFalsy();
expect(wrapper.vm.showActionInput('send_webhook_event')).toBeTruthy();
expect(wrapper.vm.showActionInput('resolve_conversation')).toBeFalsy();
expect(wrapper.vm.showActionInput('add_label')).toBeTruthy();
});
it('resetAction resets the action to default state', () => {
const data = () => {
return {
automation,
};
};
const wrapper = createComponent([methodsMixin], data);
wrapper.vm.resetAction(0);
expect(automation.actions[0].action_params).toEqual([]);
});
it('manifestConditions resets the action to default state', () => {
const data = () => {
return {
automation: {},
allCustomAttributes: customAttributes,
automationTypes: AUTOMATIONS,
};
};
const methods = {
getConditionDropdownValues() {
return statusFilterOptions;
},
};
const manifestedConditions = [
{
values: [
{
id: 'open',
name: 'Open',
},
],
attribute_key: 'status',
filter_operator: 'equal_to',
query_operator: 'and',
},
];
const wrapper = createComponent([methodsMixin], data, {}, methods);
expect(wrapper.vm.manifestConditions(savedAutomation)).toEqual(
manifestedConditions
);
});
it('generateActionsArray return the manifested actions array', () => {
const data = () => {
return {
automationActionTypes: AUTOMATION_ACTION_TYPES,
};
};
const computed = {
agents() {
return agents;
},
labels() {
return labels;
},
teams() {
return teams;
},
};
const methods = {
getActionDropdownValues() {
return [
{
id: 2,
name: 'testlabel',
},
{
id: 1,
name: 'snoozes',
},
];
},
};
const testAction = {
action_name: 'add_label',
action_params: [2],
};
const expectedActionArray = [
{
id: 2,
name: 'testlabel',
},
];
const wrapper = createComponent([methodsMixin], data, computed, methods);
expect(wrapper.vm.generateActionsArray(testAction)).toEqual(
expectedActionArray
);
});
it('manifestActions manifest the received action and generate the correct array', () => {
const data = () => {
return {
automation: {},
allCustomAttributes: customAttributes,
automationTypes: AUTOMATIONS,
};
};
const methods = {
generateActionsArray() {
return [
{
id: 2,
name: 'testlabel',
},
];
},
};
const expectedActions = [
{
action_name: 'add_label',
action_params: [
{
id: 2,
name: 'testlabel',
},
],
},
];
const wrapper = createComponent([methodsMixin], data, {}, methods);
expect(wrapper.vm.manifestActions(savedAutomation)).toEqual(
expectedActions
);
});
it('getActionDropdownValues returns Action dropdown Values', () => {
const data = () => {
return {};
};
const computed = {
agents() {
return agents;
},
labels() {
return labels;
},
teams() {
return teams;
},
slaPolicies() {
return slaPolicies;
},
};
const expectedActionDropdownValues = [
{ id: 'testlabel', name: 'testlabel' },
{ id: 'snoozes', name: 'snoozes' },
];
const wrapper = createComponent([methodsMixin], data, computed);
expect(wrapper.vm.getActionDropdownValues('add_label')).toEqual(
expectedActionDropdownValues
);
});
});
@@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import campaignMixin from '../campaignMixin';
const buildComponent = routeName => ({
template: '<div></div>',
computed: {
$route() {
return { name: routeName };
},
},
mixins: [campaignMixin],
});
describe('campaignMixin', () => {
beforeEach(() => {
global.window = Object.create(window);
});
it('returns the correct campaign type', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.campaignType).toBe('one_off');
});
it('isOneOffType returns true if path is one_off', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOneOffType).toBe(true);
});
it('isOngoingType returns true if path is ongoing_campaigns', () => {
const Component = buildComponent('ongoing_campaigns');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOngoingType).toBe(true);
});
});
@@ -0,0 +1,17 @@
import filterMixin from '../filterMixin';
import { shallowMount } from '@vue/test-utils';
import MockComponent from './MockComponent.vue';
describe('Test mixin function', () => {
const wrapper = shallowMount(MockComponent, {
mixins: [filterMixin],
});
it('should return proper value from bool', () => {
expect(wrapper.vm.setFilterAttributes).toBeTruthy();
});
it('should return proper value from bool', () => {
expect(wrapper.vm.initializeExistingFilterToModal).toBeTruthy();
});
});
@@ -0,0 +1,17 @@
import { shallowMount } from '@vue/test-utils';
import messageFormatterMixin from '../messageFormatterMixin';
describe('messageFormatterMixin', () => {
it('returns correct plain text', () => {
const Component = {
render() {},
mixins: [messageFormatterMixin],
};
const wrapper = shallowMount(Component);
const message =
'<b>Chatwoot is an opensource tool. https://www.chatwoot.com</b>';
expect(wrapper.vm.getPlainText(message)).toMatch(
'Chatwoot is an opensource tool. https://www.chatwoot.com'
);
});
});
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import PlaygroundHeader from '../../components/playground/Header.vue';
import UserMessage from '../../components/playground/UserMessage.vue';
import BotMessage from '../../components/playground/BotMessage.vue';
@@ -12,18 +12,13 @@ export default {
BotMessage,
TypingIndicator,
},
mixins: [messageFormatterMixin],
props: {
componentData: {
type: Object,
default: () => ({}),
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return { messages: [], messageContent: '', isWaiting: false };
},
+2 -6
View File
@@ -4,7 +4,7 @@ import { setHeader } from 'widget/helpers/axios';
import addHours from 'date-fns/addHours';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import configMixin from './mixins/configMixin';
import { useAvailability } from 'widget/composables/useAvailability';
import availabilityMixin from 'widget/mixins/availability';
import { getLocale } from './helpers/urlParamsHelper';
import { isEmptyObject } from 'widget/helpers/utils';
import Spinner from 'shared/components/Spinner.vue';
@@ -27,17 +27,13 @@ export default {
components: {
Spinner,
},
mixins: [configMixin, routerMixin, darkModeMixin],
mixins: [availabilityMixin, configMixin, routerMixin, darkModeMixin],
data() {
return {
isMobile: false,
campaignsSnoozedTill: undefined,
};
},
setup() {
const { isInBusinessHours } = useAvailability();
return { isInBusinessHours };
},
computed: {
...mapGetters({
activeCampaign: 'campaign/getActiveCampaign',
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import ChatCard from 'shared/components/ChatCard.vue';
import ChatForm from 'shared/components/ChatForm.vue';
import ChatOptions from 'shared/components/ChatOptions.vue';
@@ -20,7 +20,7 @@ export default {
CustomerSatisfaction,
IntegrationCard,
},
mixins: [darkModeMixin],
mixins: [messageFormatterMixin, darkModeMixin],
props: {
message: { type: String, default: null },
contentType: { type: String, default: null },
@@ -31,16 +31,6 @@ export default {
default: () => {},
},
},
setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter();
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
};
},
computed: {
isTemplate() {
return this.messageType === 3;
@@ -1,5 +1,5 @@
<script>
import { useAvailability } from 'widget/composables/useAvailability';
import availabilityMixin from 'widget/mixins/availability';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
@@ -12,7 +12,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, routerMixin, darkMixin],
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -35,11 +35,16 @@ export default {
default: () => {},
},
},
setup(props) {
const { isOnline, replyWaitMessage } = useAvailability(
props.availableAgents
);
return { isOnline, replyWaitMessage };
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
onBackButtonClick() {
@@ -5,7 +5,7 @@ import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import routerMixin from 'widget/mixins/routerMixin';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
@@ -15,19 +15,13 @@ export default {
CustomButton,
Spinner,
},
mixins: [routerMixin, darkModeMixin, configMixin],
mixins: [routerMixin, darkModeMixin, messageFormatterMixin, configMixin],
props: {
options: {
type: Object,
default: () => {},
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
data() {
return {
locale: this.$root.$i18n.locale,
@@ -1,9 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import AvailableAgents from 'widget/components/AvailableAgents.vue';
import configMixin from 'widget/mixins/configMixin';
import { useAvailability } from 'widget/composables/useAvailability';
import availabilityMixin from 'widget/mixins/availability';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { IFrameHelper } from 'widget/helpers/utils';
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
@@ -14,7 +15,7 @@ export default {
AvailableAgents,
FluentIcon,
},
mixins: [configMixin],
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
props: {
availableAgents: {
type: Array,
@@ -25,12 +26,7 @@ export default {
default: false,
},
},
setup(props) {
const { isOnline, replyWaitMessage } = useAvailability(
props.availableAgents
);
return { isOnline, replyWaitMessage };
},
computed: {
...mapGetters({
widgetColor: 'appConfig/getWidgetColor',
@@ -38,6 +34,15 @@ export default {
textColor() {
return getContrastingTextColor(this.widgetColor);
},
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
const anyAgentOnline = this.availableAgents.length > 0;
if (workingHoursEnabled) {
return this.isInBetweenTheWorkingHours;
}
return anyAgentOnline;
},
},
methods: {
startConversation() {
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import configMixin from '../mixins/configMixin';
import { isEmptyObject } from 'widget/helpers/utils';
@@ -11,7 +11,7 @@ import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
name: 'UnreadMessage',
components: { Thumbnail },
mixins: [configMixin, darkModeMixin],
mixins: [messageFormatterMixin, configMixin, darkModeMixin],
props: {
message: {
type: String,
@@ -30,12 +30,6 @@ export default {
default: null,
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
computed: {
companyName() {
return `${this.$t('UNREAD_VIEW.COMPANY_FROM')} ${
@@ -1,9 +1,10 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
name: 'UserMessageBubble',
mixins: [messageFormatterMixin],
props: {
message: {
type: String,
@@ -14,12 +15,6 @@ export default {
default: '',
},
},
setup() {
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
};
},
computed: {
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -1,5 +1,5 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
@@ -7,19 +7,13 @@ export default {
components: {
FluentIcon,
},
mixins: [darkModeMixin],
mixins: [messageFormatterMixin, darkModeMixin],
props: {
items: {
type: Array,
default: () => [],
},
},
setup() {
const { truncateMessage } = useMessageFormatter();
return {
truncateMessage,
};
},
};
</script>
@@ -1,131 +0,0 @@
import { computed } from 'vue';
import { utcToZonedTime } from 'date-fns-tz';
import { isTimeAfter } from 'shared/helpers/DateHelper';
import { useI18n } from 'dashboard/composables/useI18n';
import { useNextAvailability } from './useNextAvailability';
/**
* Composable for handling availability-related computations.
* @param {Object} t - Vue i18n instance for translations.
* @returns {Object} An object containing computed properties and methods for availability management.
*/
export function useAvailability(availableAgents) {
const { timeLeftToBackInOnline } = useNextAvailability();
const { t } = useI18n();
const channelConfig = computed(() => window.chatwootWebChannel);
const replyTime = computed(() => window.chatwootWebChannel.replyTime);
const replyTimeStatus = computed(() => {
switch (replyTime.value) {
case 'in_a_few_minutes':
return t('REPLY_TIME.IN_A_FEW_MINUTES');
case 'in_a_few_hours':
return t('REPLY_TIME.IN_A_FEW_HOURS');
case 'in_a_day':
return t('REPLY_TIME.IN_A_DAY');
default:
return t('REPLY_TIME.IN_A_FEW_HOURS');
}
});
const replyWaitMessage = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
if (workingHoursEnabled) {
return isOnline.value
? replyTimeStatus.value
: `${t('REPLY_TIME.BACK_IN')} ${timeLeftToBackInOnline.value}`;
}
return isOnline.value
? replyTimeStatus.value
: t('TEAM_AVAILABILITY.OFFLINE');
});
const outOfOfficeMessage = computed(
() => channelConfig.value.outOfOfficeMessage
);
const currentDayAvailability = computed(() => {
const { utcOffset } = channelConfig.value;
const dayOfTheWeek = getDateWithOffset(utcOffset).getDay();
const [workingHourConfig = {}] = channelConfig.value.workingHours.filter(
workingHour => workingHour.day_of_week === dayOfTheWeek
);
return {
closedAllDay: workingHourConfig.closed_all_day,
openHour: workingHourConfig.open_hour,
openMinute: workingHourConfig.open_minutes,
closeHour: workingHourConfig.close_hour,
closeMinute: workingHourConfig.close_minutes,
openAllDay: workingHourConfig.open_all_day,
};
});
const isInBetweenTheWorkingHours = computed(() => {
const {
openHour,
openMinute,
closeHour,
closeMinute,
closedAllDay,
openAllDay,
} = currentDayAvailability.value;
if (openAllDay) {
return true;
}
if (closedAllDay) {
return false;
}
const { utcOffset } = channelConfig.value;
const today = getDateWithOffset(utcOffset);
const currentHours = today.getHours();
const currentMinutes = today.getMinutes();
const isAfterStartTime = isTimeAfter(
currentHours,
currentMinutes,
openHour,
openMinute
);
const isBeforeEndTime = isTimeAfter(
closeHour,
closeMinute,
currentHours,
currentMinutes
);
return isAfterStartTime && isBeforeEndTime;
});
const isInBusinessHours = computed(() => {
const { workingHoursEnabled } = channelConfig.value;
return workingHoursEnabled ? isInBetweenTheWorkingHours.value : true;
});
const getDateWithOffset = utcOffset => {
return utcToZonedTime(new Date().toISOString(), utcOffset);
};
const isOnline = computed(() => {
if (availableAgents) {
const { workingHoursEnabled } = channelConfig.value;
const anyAgentOnline = availableAgents.length > 0;
if (workingHoursEnabled) {
return isInBetweenTheWorkingHours.value || anyAgentOnline;
}
}
return false;
});
return {
channelConfig,
replyTime,
replyTimeStatus,
replyWaitMessage,
outOfOfficeMessage,
currentDayAvailability,
isInBetweenTheWorkingHours,
isInBusinessHours,
isOnline,
getDateWithOffset,
};
}
@@ -1,281 +0,0 @@
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import {
timeSlotParse,
defaultTimeSlot,
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
import { utcToZonedTime } from 'date-fns-tz';
import { generateRelativeTime } from 'shared/helpers/DateHelper';
const MINUTE_ROUNDING_FACTOR = 5;
/**
* Composable for handling next availability calculations
* @returns {Object} An object containing computed properties and methods for next availability management
*/
export function useNextAvailability() {
const { t } = useI18n();
const dayNames = t('DAY_NAMES');
const timeSlots = ref([...defaultTimeSlot]);
const timeSlot = ref({});
const channelConfig = computed(() => window.chatwootWebChannel);
const workingHours = computed(() => channelConfig.value.workingHours);
const newDateWithTimeZone = computed(() =>
utcToZonedTime(new Date(), timeZoneValue.value)
);
const presentHour = computed(() => newDateWithTimeZone.value.getHours());
const presentMinute = computed(() => newDateWithTimeZone.value.getMinutes());
const currentDay = computed(() => {
const date = newDateWithTimeZone.value;
const day = date.getDay();
const currentDay = Object.keys(dayNames).find(
key => dayNames[key] === dayNames[day]
);
return Number(currentDay);
});
const timeZoneValue = computed(() => channelConfig.value.timezone);
const languageCode = computed(() => window.chatwootWebChannel.locale);
const currentDayWorkingHours = computed(() =>
workingHours.value.find(slot => slot.day_of_week === currentDay.value)
);
const nextDayWorkingHours = computed(() => {
let nextDay = getNextDay(currentDay.value);
let nextWorkingHour = getNextWorkingHour(nextDay);
while (!nextWorkingHour) {
nextDay = getNextDay(nextDay);
nextWorkingHour = getNextWorkingHour(nextDay);
}
return nextWorkingHour;
});
const currentDayTimings = computed(() => {
const {
open_hour: openHour,
open_minutes: openMinute,
close_hour: closeHour,
} = currentDayWorkingHours.value ?? {};
return {
openHour,
openMinute,
closeHour,
};
});
const nextDayTimings = computed(() => {
const { open_hour: openHour, open_minutes: openMinute } =
nextDayWorkingHours.value ?? {};
return {
openHour,
openMinute,
};
});
const dayDiff = computed(() => {
const nextDay = nextDayWorkingHours.value.day_of_week;
const totalDays = 6;
return nextDay > currentDay.value
? nextDay - currentDay.value - 1
: totalDays - currentDay.value + nextDay;
});
const dayNameOfNextWorkingDay = computed(
() => dayNames[nextDayWorkingHours.value.day_of_week]
);
const hoursAndMinutesBackInOnline = computed(() => {
if (presentHour.value >= currentDayTimings.value.closeHour) {
return getHoursAndMinutesUntilNextDayOpen(
nextDayWorkingHours.value.open_all_day
? 0
: nextDayTimings.value.openHour,
nextDayTimings.value.openMinute,
currentDayTimings.value.closeHour
);
}
return getHoursAndMinutesUntilNextDayOpen(
currentDayTimings.value.openHour,
currentDayTimings.value.openMinute,
currentDayTimings.value.closeHour
);
});
const exactTimeInAmPm = computed(
() =>
`${
timeSlot.value.day === currentDay.value
? `at ${timeSlot.value.from}`
: ''
}`
);
const hoursAndMinutesLeft = computed(() => {
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
const timeLeftChars = [];
if (hoursLeft > 0) {
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
const hourRelative = generateRelativeTime(
roundedUpHoursLeft,
'hour',
languageCode.value
);
timeLeftChars.push(`${hourRelative}`);
}
if (minutesLeft > 0 && hoursLeft === 0) {
const roundedUpMinLeft =
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
MINUTE_ROUNDING_FACTOR;
const minRelative = generateRelativeTime(
roundedUpMinLeft,
'minutes',
languageCode.value
);
timeLeftChars.push(`${minRelative}`);
}
return timeLeftChars.join(' ');
});
const hoursAndMinutesToBack = computed(() => {
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
if (hoursLeft >= 3) {
return exactTimeInAmPm.value;
}
if (hoursLeft > 0 || minutesLeft > 0) {
return hoursAndMinutesLeft.value;
}
return 'in some time';
});
const timeLeftToBackInOnline = computed(() => {
if (
hoursAndMinutesBackInOnline.value.hoursLeft >= 24 ||
(timeSlot.value.day !== currentDay.value && dayDiff.value === 0)
) {
const hourRelative = generateRelativeTime(
dayDiff.value + 1,
'days',
languageCode.value
);
return `${hourRelative}`;
}
if (
dayDiff.value >= 1 &&
presentHour.value >= currentDayTimings.value.closeHour
) {
return `on ${dayNameOfNextWorkingDay.value}`;
}
return hoursAndMinutesToBack.value;
});
/**
* Gets the next day of the week
* @param {number} day - Current day of the week
* @returns {number} Next day of the week
*/
function getNextDay(day) {
return (day + 1) % 7;
}
/**
* Gets the next working hour for a given day
* @param {number} day - Day of the week
* @returns {Object|null} Working hour object for the next working day, or null if not found
*/
function getNextWorkingHour(day) {
const workingHour = workingHours.value.find(
slot => slot.day_of_week === day
);
if (workingHour && !workingHour.closed_all_day) {
return workingHour;
}
return null;
}
/**
* Calculates hours and minutes until next day open
* @param {number} openHour - Opening hour
* @param {number} openMinutes - Opening minutes
* @param {number} closeHour - Closing hour
* @returns {Object} Object containing hours and minutes left
*/
function getHoursAndMinutesUntilNextDayOpen(
openHour,
openMinutes,
closeHour
) {
if (closeHour < openHour) {
openHour += 24;
}
let diffMinutes =
openHour * 60 +
openMinutes -
(presentHour.value * 60 + presentMinute.value);
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
const [hoursLeft, minutesLeft] = [
Math.floor(diffMinutes / 60),
diffMinutes % 60,
];
return { hoursLeft, minutesLeft };
}
/**
* Sets the time slot
*/
function setTimeSlot() {
const timeSlots = workingHours.value;
const currentSlot =
presentHour.value >= currentDayTimings.value.closeHour
? nextDayWorkingHours.value
: currentDayWorkingHours.value;
const slots = timeSlotParse(timeSlots).length
? timeSlotParse(timeSlots)
: defaultTimeSlot;
timeSlots.value = slots;
timeSlot.value = timeSlots.value.find(
slot => slot.day === currentSlot.day_of_week
);
}
// Call setTimeSlot initially
setTimeSlot();
return {
dayNames,
timeSlots,
timeSlot,
channelConfig,
workingHours,
newDateWithTimeZone,
presentHour,
presentMinute,
currentDay,
timeZoneValue,
languageCode,
currentDayWorkingHours,
nextDayWorkingHours,
currentDayTimings,
nextDayTimings,
dayDiff,
dayNameOfNextWorkingDay,
hoursAndMinutesBackInOnline,
exactTimeInAmPm,
hoursAndMinutesLeft,
hoursAndMinutesToBack,
timeLeftToBackInOnline,
getNextDay,
getNextWorkingHour,
getHoursAndMinutesUntilNextDayOpen,
setTimeSlot,
};
}
@@ -1,104 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref } from 'vue';
import { useAvailability } from '../../composables/useAvailability';
vi.mock('dashboard/composables/useI18n', () => ({
useI18n: () => ({
t: key => key,
}),
}));
describe('useAvailability', () => {
beforeEach(() => {
vi.useFakeTimers();
global.chatwootWebChannel = {
workingHoursEnabled: true,
workingHours: [
{
day_of_week: 3,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 35,
open_all_day: false,
},
{
day_of_week: 4,
closed_all_day: false,
open_hour: 8,
open_minutes: 30,
close_hour: 17,
close_minutes: 30,
open_all_day: false,
},
],
utcOffset: '-07:00',
replyTime: 'in_a_few_minutes',
};
});
it('returns correct replyTimeStatus', () => {
const availableAgents = ref([]);
const { replyTimeStatus } = useAvailability(availableAgents);
expect(replyTimeStatus.value).toBe('REPLY_TIME.IN_A_FEW_MINUTES');
});
it('returns correct isInBetweenTheWorkingHours when in working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 10:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns correct isInBetweenTheWorkingHours when outside working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(false);
});
it('returns true for isInBetweenTheWorkingHours when open all day', () => {
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, open_all_day: true },
];
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(true);
});
it('returns false for isInBetweenTheWorkingHours when closed all day', () => {
global.chatwootWebChannel.workingHours = [
{ day_of_week: 3, closed_all_day: true },
];
const availableAgents = ref([]);
const { isInBetweenTheWorkingHours } = useAvailability(availableAgents);
expect(isInBetweenTheWorkingHours.value).toBe(false);
});
it('returns correct isOnline status when agents are available', () => {
const availableAgents = ref([{ id: 1 }]);
const { isOnline } = useAvailability(availableAgents);
expect(isOnline.value).toBe(true);
});
it('returns correct isOnline status when no agents are available and outside working hours', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { isOnline } = useAvailability(availableAgents);
expect(isOnline.value).toBe(false);
});
it('returns correct replyWaitMessage when online', () => {
const availableAgents = ref([{ id: 1 }]);
const { replyWaitMessage } = useAvailability(availableAgents);
expect(replyWaitMessage.value).toBe('REPLY_TIME.IN_A_FEW_MINUTES');
});
it('returns correct replyWaitMessage when offline', () => {
vi.setSystemTime(new Date('Wed Apr 13 2022 18:00:00 GMT-0700'));
const availableAgents = ref([]);
const { replyWaitMessage } = useAvailability(availableAgents);
expect(replyWaitMessage.value).toBe('REPLY_TIME.BACK_IN at 08:30 AM');
});
});
+13 -17
View File
@@ -2,24 +2,22 @@
#
# Table name: account_users
#
# id :bigint not null, primary key
# active_at :datetime
# auto_offline :boolean default(TRUE), not null
# availability :integer default("online"), not null
# role :integer default("agent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# custom_role_id :bigint
# inviter_id :bigint
# user_id :bigint
# id :bigint not null, primary key
# active_at :datetime
# auto_offline :boolean default(TRUE), not null
# availability :integer default("online"), not null
# role :integer default("agent")
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint
# inviter_id :bigint
# user_id :bigint
#
# Indexes
#
# index_account_users_on_account_id (account_id)
# index_account_users_on_custom_role_id (custom_role_id)
# index_account_users_on_user_id (user_id)
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
# index_account_users_on_account_id (account_id)
# index_account_users_on_user_id (user_id)
# uniq_user_id_per_account_id (account_id,user_id) UNIQUE
#
class AccountUser < ApplicationRecord
@@ -79,6 +77,4 @@ class AccountUser < ApplicationRecord
end
end
AccountUser.prepend_mod_with('AccountUser')
AccountUser.include_mod_with('Audit::AccountUser')
AccountUser.include_mod_with('Concerns::AccountUser')
@@ -10,4 +10,3 @@ json.custom_attributes resource.custom_attributes if resource.custom_attributes.
json.name resource.name
json.role resource.role
json.thumbnail resource.avatar_url
json.custom_role_id resource.current_account_user&.custom_role_id if ChatwootApp.enterprise?

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