From 3489783cb8c650db7242656e41f7f916bfee2d4a Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 26 Aug 2024 13:05:36 +0530 Subject: [PATCH 1/4] feat: add domain blocklist feature (#10016) Co-authored-by: Pranav --- app/builders/account_builder.rb | 19 +++++++++++++++++++ config/installation_config.yml | 9 +++++++-- config/locales/en.yml | 1 + .../super_admin/app_configs_controller.rb | 2 +- lib/custom_exceptions/account.rb | 4 +++- 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/builders/account_builder.rb b/app/builders/account_builder.rb index a3a90451d..ff67eefc5 100644 --- a/app/builders/account_builder.rb +++ b/app/builders/account_builder.rb @@ -32,6 +32,8 @@ 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 @@ -79,4 +81,21 @@ 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 diff --git a/config/installation_config.yml b/config/installation_config.yml index 95d9cc6a5..26f05211e 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -168,6 +168,11 @@ display_title: 'Dashboard Scripts' description: 'Scripts are loaded as the last item in the tag' type: code +- name: BLOCKED_EMAIL_DOMAINS + value: + display_title: 'Blocked Email Domains' + description: 'Add a domain per line to block them from signing up, accepts Regex' + type: code # ------- End of Chatwoot Internal Config for Cloud ----# # ------- Chatwoot Internal Config for Self Hosted ----# @@ -220,11 +225,11 @@ ## ----- Captain Configs ----- ## - name: CAPTAIN_API_URL - value: + value: display_title: 'Captain API URL' description: 'The API URL for Captain' - name: CAPTAIN_APP_URL - value: + value: display_title: 'Captain App URL' description: 'The App URL for Captain' ## ----- End of Captain Configs ----- ## diff --git a/config/locales/en.yml b/config/locales/en.yml index 63593ef04..dd7b07bcf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -43,6 +43,7 @@ en: invalid: Invalid events signup: disposable_email: We do not allow disposable emails + blocked_domain: This domain is not allowed. If you believe this is a mistake, please contact support. invalid_email: You have entered an invalid email email_already_exists: "You have already signed up for an account with %{email}" invalid_params: 'Invalid, please check the signup paramters and try again' diff --git a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb index 126ed292e..a9c8ee1f0 100644 --- a/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb +++ b/enterprise/app/controllers/enterprise/super_admin/app_configs_controller.rb @@ -32,6 +32,6 @@ module Enterprise::SuperAdmin::AppConfigsController end def internal_config_options - %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS] + %w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS BLOCKED_EMAIL_DOMAINS] end end diff --git a/lib/custom_exceptions/account.rb b/lib/custom_exceptions/account.rb index efacc9e6e..08c6f0ddb 100644 --- a/lib/custom_exceptions/account.rb +++ b/lib/custom_exceptions/account.rb @@ -3,7 +3,9 @@ module CustomExceptions::Account class InvalidEmail < CustomExceptions::Base def message - if @data[:disposable] + if @data[:domain_blocked] + I18n.t 'errors.signup.blocked_domain' + elsif @data[:disposable] I18n.t 'errors.signup.disposable_email' elsif !@data[:valid] I18n.t 'errors.signup.invalid_email' From 7f8d718da3b987e61ba096d02fd6a366965c3228 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:55:59 +0530 Subject: [PATCH 2/4] feat: Rewrite command bar mixin to a composable (#10015) Co-authored-by: Shivam Mishra --- .../components/buttons/ResolveAction.vue | 2 +- .../components/widgets/AIAssistanceButton.vue | 2 +- .../widgets/conversation/MoreActions.vue | 2 +- .../conversationBulkActions/Index.vue | 2 +- .../composables/commands/spec/fixtures.js | 197 +++++++++ .../spec/useAppearanceHotKeys.spec.js | 83 ++++ .../spec/useBulkActionsHotKeys.spec.js | 102 +++++ .../spec/useConversationHotKeys.spec.js | 204 +++++++++ .../spec/useGoToCommandHotKeys.spec.js | 188 ++++++++ .../commands/spec/useInboxHotKeys.spec.js | 37 ++ .../commands/useAppearanceHotKeys.js | 70 +++ .../commands/useBulkActionsHotKeys.js | 89 ++++ .../commands/useConversationHotKeys.js | 408 +++++++++++++++++ .../commands/useGoToCommandHotKeys.js} | 91 ++-- .../commands/useInboxHotKeys.js} | 68 +-- .../commandbar/actions.js} | 5 +- .../commandbar/events.js} | 0 .../commandbar/icons.js} | 0 .../commands/CmdBarConversationSnooze.vue | 2 +- .../dashboard/commands/appearanceHotKeys.js | 65 --- .../dashboard/commands/bulkActionsHotKeys.js | 86 ---- .../routes/dashboard/commands/commandbar.vue | 185 ++++---- .../dashboard/commands/conversationHotKeys.js | 413 ------------------ .../inbox/components/InboxItemHeader.vue | 2 +- 24 files changed, 1547 insertions(+), 756 deletions(-) create mode 100644 app/javascript/dashboard/composables/commands/spec/fixtures.js create mode 100644 app/javascript/dashboard/composables/commands/spec/useAppearanceHotKeys.spec.js create mode 100644 app/javascript/dashboard/composables/commands/spec/useBulkActionsHotKeys.spec.js create mode 100644 app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js create mode 100644 app/javascript/dashboard/composables/commands/spec/useGoToCommandHotKeys.spec.js create mode 100644 app/javascript/dashboard/composables/commands/spec/useInboxHotKeys.spec.js create mode 100644 app/javascript/dashboard/composables/commands/useAppearanceHotKeys.js create mode 100644 app/javascript/dashboard/composables/commands/useBulkActionsHotKeys.js create mode 100644 app/javascript/dashboard/composables/commands/useConversationHotKeys.js rename app/javascript/dashboard/{routes/dashboard/commands/goToCommandHotKeys.js => composables/commands/useGoToCommandHotKeys.js} (80%) rename app/javascript/dashboard/{routes/dashboard/commands/inboxHotKeys.js => composables/commands/useInboxHotKeys.js} (56%) rename app/javascript/dashboard/{routes/dashboard/commands/commandBarActions.js => helper/commandbar/actions.js} (94%) rename app/javascript/dashboard/{routes/dashboard/commands/commandBarBusEvents.js => helper/commandbar/events.js} (100%) rename app/javascript/dashboard/{routes/dashboard/commands/CommandBarIcons.js => helper/commandbar/icons.js} (100%) delete mode 100644 app/javascript/dashboard/routes/dashboard/commands/appearanceHotKeys.js delete mode 100644 app/javascript/dashboard/routes/dashboard/commands/bulkActionsHotKeys.js delete mode 100644 app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js diff --git a/app/javascript/dashboard/components/buttons/ResolveAction.vue b/app/javascript/dashboard/components/buttons/ResolveAction.vue index 0626ced59..0ff363874 100644 --- a/app/javascript/dashboard/components/buttons/ResolveAction.vue +++ b/app/javascript/dashboard/components/buttons/ResolveAction.vue @@ -13,7 +13,7 @@ import wootConstants from 'dashboard/constants/globals'; import { CMD_REOPEN_CONVERSATION, CMD_RESOLVE_CONVERSATION, -} from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; +} from 'dashboard/helper/commandbar/events'; const store = useStore(); const getters = useStoreGetters(); diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue index 360599631..96fe2830a 100644 --- a/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue +++ b/app/javascript/dashboard/components/widgets/AIAssistanceButton.vue @@ -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/routes/dashboard/commands/commandBarBusEvents'; +import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events'; import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue'; export default { diff --git a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue index dcfc51b2a..2fc6a0752 100644 --- a/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue +++ b/app/javascript/dashboard/components/widgets/conversation/MoreActions.vue @@ -7,7 +7,7 @@ import { CMD_MUTE_CONVERSATION, CMD_SEND_TRANSCRIPT, CMD_UNMUTE_CONVERSATION, -} from '../../../routes/dashboard/commands/commandBarBusEvents'; +} from 'dashboard/helper/commandbar/events'; export default { components: { diff --git a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue index 746b932ca..b0fcaeb4b 100644 --- a/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue +++ b/app/javascript/dashboard/components/widgets/conversation/conversationBulkActions/Index.vue @@ -6,7 +6,7 @@ import { CMD_BULK_ACTION_SNOOZE_CONVERSATION, CMD_BULK_ACTION_REOPEN_CONVERSATION, CMD_BULK_ACTION_RESOLVE_CONVERSATION, -} from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; +} from 'dashboard/helper/commandbar/events'; import AgentSelector from './AgentSelector.vue'; import UpdateActions from './UpdateActions.vue'; diff --git a/app/javascript/dashboard/composables/commands/spec/fixtures.js b/app/javascript/dashboard/composables/commands/spec/fixtures.js new file mode 100644 index 000000000..d4f011f55 --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/fixtures.js @@ -0,0 +1,197 @@ +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', +}; diff --git a/app/javascript/dashboard/composables/commands/spec/useAppearanceHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useAppearanceHotKeys.spec.js new file mode 100644 index 000000000..34057f6e1 --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/useAppearanceHotKeys.spec.js @@ -0,0 +1,83 @@ +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); + }); +}); diff --git a/app/javascript/dashboard/composables/commands/spec/useBulkActionsHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useBulkActionsHotKeys.spec.js new file mode 100644 index 000000000..0a755457b --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/useBulkActionsHotKeys.spec.js @@ -0,0 +1,102 @@ +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([]); + }); +}); diff --git a/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js new file mode 100644 index 000000000..f0b858abf --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/useConversationHotKeys.spec.js @@ -0,0 +1,204 @@ +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); + }); +}); diff --git a/app/javascript/dashboard/composables/commands/spec/useGoToCommandHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useGoToCommandHotKeys.spec.js new file mode 100644 index 000000000..8eea16fdc --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/useGoToCommandHotKeys.spec.js @@ -0,0 +1,188 @@ +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); + }); +}); diff --git a/app/javascript/dashboard/composables/commands/spec/useInboxHotKeys.spec.js b/app/javascript/dashboard/composables/commands/spec/useInboxHotKeys.spec.js new file mode 100644 index 000000000..95177586a --- /dev/null +++ b/app/javascript/dashboard/composables/commands/spec/useInboxHotKeys.spec.js @@ -0,0 +1,37 @@ +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(); + }); +}); diff --git a/app/javascript/dashboard/composables/commands/useAppearanceHotKeys.js b/app/javascript/dashboard/composables/commands/useAppearanceHotKeys.js new file mode 100644 index 000000000..3dd38b0cf --- /dev/null +++ b/app/javascript/dashboard/composables/commands/useAppearanceHotKeys.js @@ -0,0 +1,70 @@ +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, + }; +} diff --git a/app/javascript/dashboard/composables/commands/useBulkActionsHotKeys.js b/app/javascript/dashboard/composables/commands/useBulkActionsHotKeys.js new file mode 100644 index 000000000..8969e7cf4 --- /dev/null +++ b/app/javascript/dashboard/composables/commands/useBulkActionsHotKeys.js @@ -0,0 +1,89 @@ +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, + }; +} diff --git a/app/javascript/dashboard/composables/commands/useConversationHotKeys.js b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js new file mode 100644 index 000000000..6e6004e45 --- /dev/null +++ b/app/javascript/dashboard/composables/commands/useConversationHotKeys.js @@ -0,0 +1,408 @@ +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, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/commands/goToCommandHotKeys.js b/app/javascript/dashboard/composables/commands/useGoToCommandHotKeys.js similarity index 80% rename from app/javascript/dashboard/routes/dashboard/commands/goToCommandHotKeys.js rename to app/javascript/dashboard/composables/commands/useGoToCommandHotKeys.js index 6b16bf93d..c790cda42 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/goToCommandHotKeys.js +++ b/app/javascript/dashboard/composables/commands/useGoToCommandHotKeys.js @@ -1,3 +1,8 @@ +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, @@ -14,11 +19,9 @@ import { ICON_TEAM_REPORTS, ICON_USER_PROFILE, ICON_CONVERSATION_REPORTS, -} from './CommandBarIcons'; -import { frontendURL } from '../../../helper/URLHelper'; -import { mapGetters } from 'vuex'; -import { useAdmin } from 'dashboard/composables/useAdmin'; -import { FEATURE_FLAGS } from '../../../featureFlags'; +} from 'dashboard/helper/commandbar/icons'; +import { frontendURL } from 'dashboard/helper/URLHelper'; +import { FEATURE_FLAGS } from 'dashboard/featureFlags'; const GO_TO_COMMANDS = [ { @@ -172,45 +175,45 @@ const GO_TO_COMMANDS = [ }, ]; -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; - }); +export function useGoToCommandHotKeys() { + const { t } = useI18n(); + const router = useRouter(); + const { isAdmin } = useAdmin(); - if (!this.isAdmin) { - commands = commands.filter(command => command.role.includes('agent')); + 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 + ); } + return true; + }); - 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)); - }, - }, -}; + 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, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/commands/inboxHotKeys.js b/app/javascript/dashboard/composables/commands/useInboxHotKeys.js similarity index 56% rename from app/javascript/dashboard/routes/dashboard/commands/inboxHotKeys.js rename to app/javascript/dashboard/composables/commands/useInboxHotKeys.js index 37b1986f0..fc11bcf53 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/inboxHotKeys.js +++ b/app/javascript/dashboard/composables/commands/useInboxHotKeys.js @@ -1,13 +1,19 @@ +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 './commandBarBusEvents'; -import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons'; +import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events'; +import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons'; 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', @@ -21,8 +27,7 @@ const INBOX_SNOOZE_EVENTS = [ parent: 'snooze_notification', section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', icon: ICON_SNOOZE_NOTIFICATION, - handler: () => - emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW), + handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW), }, { id: SNOOZE_OPTIONS.UNTIL_TOMORROW, @@ -30,8 +35,7 @@ const INBOX_SNOOZE_EVENTS = [ section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', parent: 'snooze_notification', icon: ICON_SNOOZE_NOTIFICATION, - handler: () => - emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW), + handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW), }, { id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK, @@ -39,8 +43,7 @@ const INBOX_SNOOZE_EVENTS = [ section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', parent: 'snooze_notification', icon: ICON_SNOOZE_NOTIFICATION, - handler: () => - emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK), + handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK), }, { id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH, @@ -48,8 +51,7 @@ const INBOX_SNOOZE_EVENTS = [ section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', parent: 'snooze_notification', icon: ICON_SNOOZE_NOTIFICATION, - handler: () => - emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH), + handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH), }, { id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME, @@ -57,26 +59,30 @@ const INBOX_SNOOZE_EVENTS = [ section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', parent: 'snooze_notification', icon: ICON_SNOOZE_NOTIFICATION, - handler: () => - emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME), + handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME), }, ]; -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), - })); - }, - }, -}; + +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, + }; +} diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandBarActions.js b/app/javascript/dashboard/helper/commandbar/actions.js similarity index 94% rename from app/javascript/dashboard/routes/dashboard/commands/commandBarActions.js rename to app/javascript/dashboard/helper/commandbar/actions.js index c47c0a7ce..457754eb7 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandBarActions.js +++ b/app/javascript/dashboard/helper/commandbar/actions.js @@ -8,7 +8,7 @@ import { CMD_SEND_TRANSCRIPT, CMD_SNOOZE_CONVERSATION, CMD_UNMUTE_CONVERSATION, -} from './commandBarBusEvents'; +} from 'dashboard/helper/commandbar/events'; import { ICON_MUTE_CONVERSATION, @@ -17,7 +17,7 @@ import { ICON_SEND_TRANSCRIPT, ICON_SNOOZE_CONVERSATION, ICON_UNMUTE_CONVERSATION, -} from './CommandBarIcons'; +} from 'dashboard/helper/commandbar/icons'; const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS; @@ -46,6 +46,7 @@ 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), }, diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandBarBusEvents.js b/app/javascript/dashboard/helper/commandbar/events.js similarity index 100% rename from app/javascript/dashboard/routes/dashboard/commands/commandBarBusEvents.js rename to app/javascript/dashboard/helper/commandbar/events.js diff --git a/app/javascript/dashboard/routes/dashboard/commands/CommandBarIcons.js b/app/javascript/dashboard/helper/commandbar/icons.js similarity index 100% rename from app/javascript/dashboard/routes/dashboard/commands/CommandBarIcons.js rename to app/javascript/dashboard/helper/commandbar/icons.js diff --git a/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue b/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue index 529b624b0..b55d57801 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/CmdBarConversationSnooze.vue @@ -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/routes/dashboard/commands/commandBarBusEvents'; +import { CMD_SNOOZE_CONVERSATION } from 'dashboard/helper/commandbar/events'; import wootConstants from 'dashboard/constants/globals'; import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue'; diff --git a/app/javascript/dashboard/routes/dashboard/commands/appearanceHotKeys.js b/app/javascript/dashboard/routes/dashboard/commands/appearanceHotKeys.js deleted file mode 100644 index 493a95e0c..000000000 --- a/app/javascript/dashboard/routes/dashboard/commands/appearanceHotKeys.js +++ /dev/null @@ -1,65 +0,0 @@ -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); - }, - }, -}; diff --git a/app/javascript/dashboard/routes/dashboard/commands/bulkActionsHotKeys.js b/app/javascript/dashboard/routes/dashboard/commands/bulkActionsHotKeys.js deleted file mode 100644 index 74e68afc6..000000000 --- a/app/javascript/dashboard/routes/dashboard/commands/bulkActionsHotKeys.js +++ /dev/null @@ -1,86 +0,0 @@ -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), - })); - }, - }, -}; diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue index 01893bbf9..c798336b2 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue @@ -1,118 +1,85 @@ - diff --git a/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js b/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js deleted file mode 100644 index 652624872..000000000 --- a/app/javascript/dashboard/routes/dashboard/commands/conversationHotKeys.js +++ /dev/null @@ -1,413 +0,0 @@ -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), - })); - }, - }, -}; diff --git a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue index 271c4460a..1b2895808 100644 --- a/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/inbox/components/InboxItemHeader.vue @@ -2,7 +2,7 @@ import { mapGetters } from 'vuex'; import { useAlert } from 'dashboard/composables'; import { getUnixTime } from 'date-fns'; -import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents'; +import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events'; import wootConstants from 'dashboard/constants/globals'; import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers'; import { INBOX_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; From 32c25047c4dab96f3eb0c034c42d8e354f4aff61 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 27 Aug 2024 08:00:05 +0530 Subject: [PATCH 3/4] feat: Rewrite `reportMixin` to a composable (#10029) # Pull Request Template ## Description The PR will replace the usage of `reportMixin` with the help of `useReportMetrics()` composable. Fixes https://linear.app/chatwoot/issue/CW-3450/rewrite-reportmixin-mixin-to-a-composable **Files updated** 1. dashboard/routes/dashboard/settings/reports/Index.vue 2. dashboard/routes/dashboard/settings/reports/BotReports.vue 3. dashboard/routes/dashboard/settings/reports/ReportContainer.vue 4. dashboard/routes/dashboard/settings/reports/components/WootReports.vue 5. dashboard/routes/dashboard/settings/reports/components/ChartElements/ChartStats.vue ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Test the all the reports view. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../spec/fixtures/reportFixtures.js | 37 +++++ .../composables/spec/useReportMetrics.spec.js | 61 ++++++++ .../dashboard/composables/useReportMetrics.js | 57 ++++++++ .../dashboard/mixins/reportMixin.js | 51 ------- .../mixins/specs/reportMixin.spec.js | 136 ------------------ .../mixins/specs/reportMixinFixtures.js | 37 ----- .../dashboard/settings/reports/BotReports.vue | 2 - .../dashboard/settings/reports/Index.vue | 2 - .../settings/reports/ReportContainer.vue | 25 +++- .../components/ChartElements/ChartStats.vue | 47 +++--- .../reports/components/WootReports.vue | 2 - 11 files changed, 200 insertions(+), 257 deletions(-) create mode 100644 app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js create mode 100644 app/javascript/dashboard/composables/spec/useReportMetrics.spec.js create mode 100644 app/javascript/dashboard/composables/useReportMetrics.js delete mode 100644 app/javascript/dashboard/mixins/reportMixin.js delete mode 100644 app/javascript/dashboard/mixins/specs/reportMixin.spec.js delete mode 100644 app/javascript/dashboard/mixins/specs/reportMixinFixtures.js diff --git a/app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js b/app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js new file mode 100644 index 000000000..93880e1ca --- /dev/null +++ b/app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js @@ -0,0 +1,37 @@ +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 }, + ], +}; diff --git a/app/javascript/dashboard/composables/spec/useReportMetrics.spec.js b/app/javascript/dashboard/composables/spec/useReportMetrics.spec.js new file mode 100644 index 000000000..194f07e75 --- /dev/null +++ b/app/javascript/dashboard/composables/spec/useReportMetrics.spec.js @@ -0,0 +1,61 @@ +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'); + }); +}); diff --git a/app/javascript/dashboard/composables/useReportMetrics.js b/app/javascript/dashboard/composables/useReportMetrics.js new file mode 100644 index 000000000..37168e256 --- /dev/null +++ b/app/javascript/dashboard/composables/useReportMetrics.js @@ -0,0 +1,57 @@ +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, + }; +} diff --git a/app/javascript/dashboard/mixins/reportMixin.js b/app/javascript/dashboard/mixins/reportMixin.js deleted file mode 100644 index d57af1ad2..000000000 --- a/app/javascript/dashboard/mixins/reportMixin.js +++ /dev/null @@ -1,51 +0,0 @@ -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); - }, - }, -}; diff --git a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js b/app/javascript/dashboard/mixins/specs/reportMixin.spec.js deleted file mode 100644 index c0bc1e15f..000000000 --- a/app/javascript/dashboard/mixins/specs/reportMixin.spec.js +++ /dev/null @@ -1,136 +0,0 @@ -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(''); - }); -}); diff --git a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js b/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js deleted file mode 100644 index 591bf7c1f..000000000 --- a/app/javascript/dashboard/mixins/specs/reportMixinFixtures.js +++ /dev/null @@ -1,37 +0,0 @@ -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 }, - ], - }, -}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue index 492775542..78b433a42 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/BotReports.vue @@ -3,7 +3,6 @@ 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'; @@ -14,7 +13,6 @@ export default { ReportFilterSelector, ReportContainer, }, - mixins: [reportMixin], data() { return { from: 0, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index 1f96b161f..da2f2f232 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -4,7 +4,6 @@ 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'; @@ -24,7 +23,6 @@ export default { ReportFilterSelector, ReportContainer, }, - mixins: [reportMixin], data() { return { from: 0, diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue index f90c3730c..fc323d037 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/ReportContainer.vue @@ -1,19 +1,23 @@ @@ -29,7 +34,7 @@ export default { {{ metric.NAME }}
-
+
{{ displayMetric(metric.KEY) }}
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue index 2d5e52462..51fb8932d 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/WootReports.vue @@ -3,7 +3,6 @@ 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'; @@ -22,7 +21,6 @@ export default { ReportFilters, ReportContainer, }, - mixins: [reportMixin], props: { type: { type: String, From f82ec3b885ec2e52b460da0522b6f9c69a33fa54 Mon Sep 17 00:00:00 2001 From: Fayaz Ahmed Date: Tue, 27 Aug 2024 08:06:51 +0530 Subject: [PATCH 4/4] chore: Repalce message formatter mixin with useMessageFormatter [CW-3470] (#9986) # Pull Request Template ## Description Replaced the old messageFormatterMixin with a useMessageFormatter composable --- .../components/widgets/AIAssistanceModal.vue | 10 +-- .../widgets/conversation/Message.vue | 9 +- .../widgets/conversation/MessagePreview.vue | 9 +- .../widgets/conversation/ReplyBox.vue | 8 +- .../components/MessageContextMenu.vue | 9 +- .../modules/notes/components/ContactNote.vue | 11 ++- .../search/components/MessageContent.vue | 10 ++- .../conversation/search/PopOverSearch.vue | 2 - .../dashboard/settings/billing/Index.vue | 6 +- .../settings/campaigns/CampaignCard.vue | 10 ++- .../dashboard/settings/integrations/Slack.vue | 3 +- .../Slack/SelectChannelWarning.vue | 10 ++- .../Slack/SlackIntegrationHelpText.vue | 9 +- .../portal/components/SearchSuggestions.vue | 6 +- .../shared/components/ChatOptions.vue | 9 +- .../specs/useMessageFormatter.spec.js | 79 ++++++++++++++++++ .../shared/composables/useMessageFormatter.js | 82 +++++++++++++++++++ .../shared/mixins/messageFormatterMixin.js | 40 --------- .../specs/messageFormatterMixin.spec.js | 17 ---- .../views/playground/Index.vue | 9 +- .../widget/components/AgentMessageBubble.vue | 14 +++- .../widget/components/PreChat/Form.vue | 10 ++- .../widget/components/UnreadMessage.vue | 10 ++- .../widget/components/UserMessageBubble.vue | 9 +- .../widget/components/template/Article.vue | 10 ++- 25 files changed, 287 insertions(+), 114 deletions(-) create mode 100644 app/javascript/shared/composables/specs/useMessageFormatter.spec.js create mode 100644 app/javascript/shared/composables/useMessageFormatter.js delete mode 100644 app/javascript/shared/mixins/messageFormatterMixin.js delete mode 100644 app/javascript/shared/mixins/specs/messageFormatterMixin.spec.js diff --git a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue b/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue index 547746131..8f3100d80 100644 --- a/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue +++ b/app/javascript/dashboard/components/widgets/AIAssistanceModal.vue @@ -1,13 +1,12 @@ diff --git a/app/javascript/portal/components/SearchSuggestions.vue b/app/javascript/portal/components/SearchSuggestions.vue index 5838e8c7c..cb4adc9fc 100644 --- a/app/javascript/portal/components/SearchSuggestions.vue +++ b/app/javascript/portal/components/SearchSuggestions.vue @@ -1,10 +1,9 @@