Restructure omni services and add Chatwoot research snapshot

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

View File

@@ -0,0 +1,12 @@
export const CAPTAIN_ERROR_TYPES = Object.freeze({
ABORTED: 'aborted',
API_ERROR: 'api_error',
HTTP_PREFIX: 'http_',
ABORT_ERROR: 'AbortError',
CANCELED_ERROR: 'CanceledError',
});
export const CAPTAIN_GENERATION_FAILURE_REASONS = Object.freeze({
EMPTY_RESPONSE: 'empty_response',
EXCEPTION: 'exception',
});

View File

@@ -0,0 +1,218 @@
import { ref, unref } from 'vue';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConversationRequiredAttributes } from 'dashboard/composables/useConversationRequiredAttributes';
import wootConstants from 'dashboard/constants/globals';
export function useBulkActions() {
const store = useStore();
const { t } = useI18n();
const { checkMissingAttributes } = useConversationRequiredAttributes();
const selectedConversations = useMapGetter(
'bulkActions/getSelectedConversationIds'
);
const selectedInboxes = ref([]);
function selectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/setSelectedConversationIds', conversationId);
selectedInboxes.value = [...selectedInboxes.value, inboxId];
}
function deSelectConversation(conversationId, inboxId) {
store.dispatch('bulkActions/removeSelectedConversationIds', conversationId);
selectedInboxes.value = selectedInboxes.value.filter(
item => item !== inboxId
);
}
function resetBulkActions() {
store.dispatch('bulkActions/clearSelectedConversationIds');
selectedInboxes.value = [];
}
function selectAllConversations(check, conversationList) {
const availableConversations = unref(conversationList);
if (check) {
store.dispatch(
'bulkActions/setSelectedConversationIds',
availableConversations.map(item => item.id)
);
selectedInboxes.value = availableConversations.map(item => item.inbox_id);
} else {
resetBulkActions();
}
}
function isConversationSelected(id) {
return selectedConversations.value.includes(id);
}
// Same method used in context menu, conversationId being passed from there.
async function onAssignAgent(agent, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
fields: {
assignee_id: agent.id,
},
});
store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.AGENT_ASSIGNMENT.SUCCESFUL', {
agentName: agent.name,
conversationId,
})
);
} else {
useAlert(t('BULK_ACTION.ASSIGN_SUCCESFUL'));
}
} catch (err) {
useAlert(t('BULK_ACTION.ASSIGN_FAILED'));
}
}
// Same method used in context menu, conversationId being passed from there.
async function onAssignLabels(newLabels, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
labels: {
add: newLabels,
},
});
store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_ASSIGNMENT.SUCCESFUL', {
labelName: newLabels[0],
conversationId,
})
);
} else {
useAlert(t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
}
} catch (err) {
useAlert(t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
}
// Only used in context menu
async function onRemoveLabels(labelsToRemove, conversationId = null) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationId || selectedConversations.value,
labels: {
remove: labelsToRemove,
},
});
useAlert(
t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.SUCCESFUL', {
labelName: labelsToRemove[0],
conversationId,
})
);
} catch (err) {
useAlert(t('CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_REMOVAL.FAILED'));
}
}
async function onAssignTeamsForBulk(team) {
try {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: selectedConversations.value,
fields: {
team_id: team.id,
},
});
store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
useAlert(t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
}
async function onUpdateConversations(status, snoozedUntil) {
let conversationIds = selectedConversations.value;
let skippedCount = 0;
// If resolving, check for required attributes
if (status === wootConstants.STATUS_TYPE.RESOLVED) {
const { validIds, skippedIds } = selectedConversations.value.reduce(
(acc, id) => {
const conversation = store.getters.getConversationById(id);
const currentCustomAttributes = conversation?.custom_attributes || {};
const { hasMissing } = checkMissingAttributes(
currentCustomAttributes
);
if (!hasMissing) {
acc.validIds.push(id);
} else {
acc.skippedIds.push(id);
}
return acc;
},
{ validIds: [], skippedIds: [] }
);
conversationIds = validIds;
skippedCount = skippedIds.length;
if (skippedCount > 0 && validIds.length === 0) {
// All conversations have missing attributes
useAlert(
t('BULK_ACTION.RESOLVE.ALL_MISSING_ATTRIBUTES') ||
'Cannot resolve conversations due to missing required attributes'
);
return;
}
}
try {
if (conversationIds.length > 0) {
await store.dispatch('bulkActions/process', {
type: 'Conversation',
ids: conversationIds,
fields: {
status,
},
snoozed_until: snoozedUntil,
});
}
store.dispatch('bulkActions/clearSelectedConversationIds');
if (skippedCount > 0) {
useAlert(t('BULK_ACTION.RESOLVE.PARTIAL_SUCCESS'));
} else {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
}
} catch (err) {
useAlert(t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
}
return {
selectedConversations,
selectedInboxes,
selectConversation,
deSelectConversation,
selectAllConversations,
resetBulkActions,
isConversationSelected,
onAssignAgent,
onAssignLabels,
onRemoveLabels,
onAssignTeamsForBulk,
onUpdateConversations,
};
}

View File

@@ -0,0 +1,60 @@
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
export function useChatListKeyboardEvents(listRef) {
const getKeyboardListenerParams = () => {
const allConversations = listRef.value.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = listRef.value.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
};
const handleConversationNavigation = direction => {
const { allConversations, activeConversationIndex, lastConversationIndex } =
getKeyboardListenerParams();
// Determine the new index based on the direction
const newIndex =
direction === 'previous'
? activeConversationIndex - 1
: activeConversationIndex + 1;
// Check if the new index is within the valid range
if (
allConversations.length > 0 &&
newIndex >= 0 &&
newIndex <= lastConversationIndex
) {
// Click the conversation at the new index
allConversations[newIndex].click();
} else if (allConversations.length > 0) {
// If the new index is out of range, click the first or last conversation based on the direction
const fallbackIndex =
direction === 'previous' ? 0 : lastConversationIndex;
allConversations[fallbackIndex].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
}

View File

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

View File

@@ -0,0 +1,83 @@
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
import { useI18n } from 'vue-i18n';
import { LocalStorage } from 'shared/helpers/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
vi.mock('vue-i18n');
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);
});
});

View File

@@ -0,0 +1,102 @@
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
vi.mock('dashboard/composables/store');
vi.mock('vue-i18n');
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([]);
});
});

View File

@@ -0,0 +1,204 @@
import { useConversationHotKeys } from '../useConversationHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useCaptain } from 'dashboard/composables/useCaptain';
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('vue-i18n');
vi.mock('vue-router');
vi.mock('dashboard/composables/useConversationLabels');
vi.mock('dashboard/composables/useCaptain');
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(),
});
useCaptain.mockReturnValue({ captainTasksEnabled: { 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 captain tasks 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 captain tasks is disabled', () => {
useCaptain.mockReturnValue({ captainTasksEnabled: { 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);
});
});

View File

@@ -0,0 +1,188 @@
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
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('vue-i18n');
vi.mock('vue-router');
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);
});
});

View File

@@ -0,0 +1,37 @@
import { useInboxHotKeys } from '../useInboxHotKeys';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
vi.mock('vue-i18n');
vi.mock('vue-router');
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();
});
});

View File

@@ -0,0 +1,70 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
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,
};
}

View File

@@ -0,0 +1,89 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
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,
};
}

View File

@@ -0,0 +1,403 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { emitter } from 'shared/helpers/mitt';
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
import { useCaptain } from 'dashboard/composables/useCaptain';
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.CONFIDENT'),
key: 'confident',
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.PROFESSIONAL'),
key: 'professional',
icon: ICON_AI_EXPAND,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.CASUAL'),
key: 'casual',
icon: ICON_AI_SHORTEN,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
key: 'friendly',
icon: ICON_AI_ASSIST,
},
{
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.STRAIGHTFORWARD'),
key: 'straightforward',
icon: ICON_AI_ASSIST,
},
];
};
export function useConversationHotKeys() {
const { t } = useI18n();
const store = useStore();
const route = useRoute();
const {
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
} = useConversationLabels();
const { captainTasksEnabled } = useCaptain();
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 (captainTasksEnabled.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,
};
}

View File

@@ -0,0 +1,219 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import { useRouter } from 'vue-router';
import { useAdmin } from 'dashboard/composables/useAdmin';
import {
ICON_ACCOUNT_SETTINGS,
ICON_AGENT_REPORTS,
ICON_APPS,
ICON_CANNED_RESPONSE,
ICON_CONTACT_DASHBOARD,
ICON_CONVERSATION_DASHBOARD,
ICON_INBOXES,
ICON_INBOX_REPORTS,
ICON_LABELS,
ICON_LABEL_REPORTS,
ICON_NOTIFICATION,
ICON_REPORTS_OVERVIEW,
ICON_TEAM_REPORTS,
ICON_USER_PROFILE,
ICON_CONVERSATION_REPORTS,
} from 'dashboard/helper/commandbar/icons';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const GO_TO_COMMANDS = [
{
id: 'goto_conversation_dashboard',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_DASHBOARD',
section: 'COMMAND_BAR.SECTIONS.GENERAL',
icon: ICON_CONVERSATION_DASHBOARD,
path: accountId => `accounts/${accountId}/dashboard`,
role: ['administrator', 'agent'],
},
{
id: 'goto_contacts_dashboard',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONTACTS_DASHBOARD',
section: 'COMMAND_BAR.SECTIONS.GENERAL',
featureFlag: FEATURE_FLAGS.CRM,
icon: ICON_CONTACT_DASHBOARD,
path: accountId => `accounts/${accountId}/contacts`,
role: ['administrator', 'agent'],
},
{
id: 'open_reports_overview',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_REPORTS_OVERVIEW',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_REPORTS_OVERVIEW,
path: accountId => `accounts/${accountId}/reports/overview`,
role: ['administrator'],
},
{
id: 'open_conversation_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_CONVERSATION_REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_CONVERSATION_REPORTS,
path: accountId => `accounts/${accountId}/reports/conversation`,
role: ['administrator'],
},
{
id: 'open_agent_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_AGENT_REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_AGENT_REPORTS,
path: accountId => `accounts/${accountId}/reports/agent`,
role: ['administrator'],
},
{
id: 'open_label_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_LABEL_REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_LABEL_REPORTS,
path: accountId => `accounts/${accountId}/reports/label`,
role: ['administrator'],
},
{
id: 'open_inbox_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_INBOX_REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_INBOX_REPORTS,
path: accountId => `accounts/${accountId}/reports/inboxes`,
role: ['administrator'],
},
{
id: 'open_team_reports',
section: 'COMMAND_BAR.SECTIONS.REPORTS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_TEAM_REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
icon: ICON_TEAM_REPORTS,
path: accountId => `accounts/${accountId}/reports/teams`,
role: ['administrator'],
},
{
id: 'open_agent_settings',
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_AGENTS',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
icon: ICON_AGENT_REPORTS,
path: accountId => `accounts/${accountId}/settings/agents/list`,
role: ['administrator'],
},
{
id: 'open_team_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_TEAMS',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_TEAM_REPORTS,
path: accountId => `accounts/${accountId}/settings/teams/list`,
role: ['administrator'],
},
{
id: 'open_inbox_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_INBOXES',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_INBOXES,
path: accountId => `accounts/${accountId}/settings/inboxes/list`,
role: ['administrator'],
},
{
id: 'open_label_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_LABELS',
featureFlag: FEATURE_FLAGS.LABELS,
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_LABELS,
path: accountId => `accounts/${accountId}/settings/labels/list`,
role: ['administrator'],
},
{
id: 'open_canned_response_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_CANNED_RESPONSES',
featureFlag: FEATURE_FLAGS.CANNED_RESPONSES,
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_CANNED_RESPONSE,
path: accountId => `accounts/${accountId}/settings/canned-response/list`,
role: ['administrator', 'agent'],
},
{
id: 'open_applications_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_APPLICATIONS',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_APPS,
path: accountId => `accounts/${accountId}/settings/applications`,
role: ['administrator'],
},
{
id: 'open_account_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_ACCOUNT',
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_ACCOUNT_SETTINGS,
path: accountId => `accounts/${accountId}/settings/general`,
role: ['administrator'],
},
{
id: 'open_profile_settings',
title: 'COMMAND_BAR.COMMANDS.GO_TO_SETTINGS_PROFILE',
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_USER_PROFILE,
path: accountId => `accounts/${accountId}/profile/settings`,
role: ['administrator', 'agent'],
},
{
id: 'open_notifications',
title: 'COMMAND_BAR.COMMANDS.GO_TO_NOTIFICATIONS',
section: 'COMMAND_BAR.SECTIONS.SETTINGS',
icon: ICON_NOTIFICATION,
path: accountId => `accounts/${accountId}/notifications`,
role: ['administrator', 'agent'],
},
];
export function useGoToCommandHotKeys() {
const { t } = useI18n();
const router = useRouter();
const { isAdmin } = useAdmin();
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;
});
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,
};
}

View File

@@ -0,0 +1,88 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
import { 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',
title: 'COMMAND_BAR.COMMANDS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
children: Object.values(SNOOZE_OPTIONS),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_notification',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.UNTIL_CUSTOM_TIME',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION,
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
];
export function useInboxHotKeys() {
const { t } = useI18n();
const route = useRoute();
const prepareActions = actions => {
return actions.map(action => ({
...action,
title: t(action.title),
section: action.section ? t(action.section) : undefined,
}));
};
const inboxHotKeys = computed(() => {
if (isAInboxViewRoute(route.name)) {
return prepareActions(INBOX_SNOOZE_EVENTS);
}
return [];
});
return {
inboxHotKeys,
};
}

View File

@@ -0,0 +1,20 @@
import { emitter } from 'shared/helpers/mitt';
import { onMounted, onBeforeUnmount } from 'vue';
// this will automatically add event listeners to the emitter
// and remove them when the component is destroyed
const useEmitter = (eventName, callback) => {
const cleanup = () => {
emitter.off(eventName, callback);
};
onMounted(() => {
emitter.on(eventName, callback);
});
onBeforeUnmount(cleanup);
return cleanup;
};
export { useEmitter };

View File

@@ -0,0 +1,24 @@
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper/index';
/**
* Custom hook to track events
*/
export const useTrack = (...args) => {
try {
return analyticsHelper.track(...args);
} catch (error) {
// Ignore this, tracking is not mission critical
}
return null;
};
/**
* Emits a toast message event using a global emitter.
* @param {string} message - The message to be displayed in the toast.
* @param {Object|null} action - Optional callback function or object to execute.
*/
export const useAlert = (message, action = null) => {
emitter.emit('newToastMessage', { message, action });
};

View File

@@ -0,0 +1,57 @@
import { ref } from 'vue';
export const useLoadWithRetry = (config = {}) => {
const maxRetry = config.max_retry || 3;
const backoff = config.backoff || 1000;
const isLoaded = ref(false);
const hasError = ref(false);
const loadWithRetry = async url => {
const attemptLoad = () => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
isLoaded.value = true;
hasError.value = false;
resolve();
};
img.onerror = () => {
reject(new Error('Failed to load image'));
};
img.src = url;
});
};
const sleep = ms => {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
};
const retry = async (attempt = 0) => {
try {
await attemptLoad();
} catch (error) {
if (attempt + 1 >= maxRetry) {
hasError.value = true;
isLoaded.value = false;
return;
}
await sleep(backoff * (attempt + 1));
await retry(attempt + 1);
}
};
await retry();
};
return {
isLoaded,
hasError,
loadWithRetry,
};
};

View File

@@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import { emitter } from 'shared/helpers/mitt';
import { useEmitter } from '../emitter';
import { defineComponent } from 'vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
on: vi.fn(),
off: vi.fn(),
},
}));
describe('useEmitter', () => {
const eventName = 'my-event';
const callback = vi.fn();
let wrapper;
const TestComponent = defineComponent({
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
template: '<div>Hello world</div>',
});
beforeEach(() => {
wrapper = shallowMount(TestComponent);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should add an event listener on mount', () => {
expect(emitter.on).toHaveBeenCalledWith(eventName, callback);
});
it('should remove the event listener when the component is unmounted', async () => {
await wrapper.unmount();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
it('should return the cleanup function', () => {
const cleanup = wrapper.vm.cleanup;
expect(typeof cleanup).toBe('function');
cleanup();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
});

View File

@@ -0,0 +1,63 @@
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
export { allAgentsData };
export const formattedAgentsData = [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];

View File

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

View File

@@ -0,0 +1,50 @@
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { useTrack, useAlert } from '../index';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
describe('useTrack', () => {
it('should call analyticsHelper.track and return a function', () => {
const eventArgs = ['event-name', { some: 'data' }];
useTrack(...eventArgs);
expect(analyticsHelper.track).toHaveBeenCalledWith(...eventArgs);
});
});
describe('useAlert', () => {
it('should emit a newToastMessage event with the provided message and action', () => {
const message = 'Toast message';
const action = {
type: 'link',
to: '/app/accounts/1/conversations/1',
message: 'Navigate',
};
useAlert(message, action);
expect(emitter.emit).toHaveBeenCalledWith('newToastMessage', {
message,
action,
});
});
it('should emit a newToastMessage event with the provided message and no action if action is null', () => {
const message = 'Toast message';
useAlert(message);
expect(emitter.emit).toHaveBeenCalledWith('newToastMessage', {
message,
action: null,
});
});
});

View File

@@ -0,0 +1,117 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAccount } from '../useAccount';
import { useRoute } from 'vue-router';
import { mount } from '@vue/test-utils';
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => ({
accounts: [
{ id: 1, name: 'Chatwoot', role: 'administrator' },
{ id: 2, name: 'GitX', role: 'agent' },
],
}),
},
},
accounts: {
namespaced: true,
getters: {
getAccount: () => id => ({ id, name: 'Chatwoot' }),
},
},
},
});
const mountParams = {
global: {
plugins: [store],
},
};
vi.mock('vue-router');
describe('useAccount', () => {
beforeEach(() => {
useRoute.mockReturnValue({
params: {
accountId: '123',
},
});
});
const createComponent = () =>
defineComponent({
setup() {
return useAccount();
},
render() {
return h('div'); // Dummy render to satisfy mount
},
});
it('returns accountId as a computed property', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountId } = wrapper.vm;
expect(accountId).toBe(123);
});
it('generates account-scoped URLs correctly', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('settings/inbox/new');
expect(result).toBe('/app/accounts/123/settings/inbox/new');
});
it('handles URLs with leading slash', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('users');
expect(result).toBe('/app/accounts/123/users'); // Ensures no double slashes
});
it('handles empty URL', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('');
expect(result).toBe('/app/accounts/123/');
});
it('returns current account based on accountId', () => {
const wrapper = mount(createComponent(), mountParams);
const { currentAccount } = wrapper.vm;
expect(currentAccount).toEqual({ id: 123, name: 'Chatwoot' });
});
it('returns an account-scoped route', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedRoute } = wrapper.vm;
const result = accountScopedRoute('accountDetail', { userId: 456 }, {});
expect(result).toEqual({
name: 'accountDetail',
params: { accountId: 123, userId: 456 },
query: {},
});
});
it('returns route with correct params', () => {
const wrapper = mount(createComponent(), mountParams);
const { route } = wrapper.vm;
expect(route.params).toEqual({ accountId: '123' });
});
it('handles non-numeric accountId gracefully', async () => {
useRoute.mockReturnValueOnce({
params: { accountId: 'abc' },
});
const wrapper = mount(createComponent(), mountParams);
const { accountId } = wrapper.vm;
expect(accountId).toBeNaN(); // Handles invalid numeric conversion
});
});

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue';
import { useAdmin } from '../useAdmin';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useAdmin', () => {
it('returns true if the current user is an administrator', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref('administrator'),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(true);
});
it('returns false if the current user is not an administrator', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref('user'),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is null', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(null),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is undefined', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(undefined),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is an empty string', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(''),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: key => (key === 'AGENT_MGMT.MULTI_SELECTOR.LIST.NONE' ? 'None' : key),
}),
}));
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
// Create a mock None agent
const mockNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
};
describe('useAgentsList', () => {
beforeEach(() => {
vi.clearAllMocks();
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
mockUseMapGetter();
});
it('returns agentsList and assignableAgents', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0].id).not.toBe(0);
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
it('handles missing inbox_id', () => {
mockUseMapGetter({
getSelectedChat: ref({ meta: { assignee: true } }),
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
});

View File

@@ -0,0 +1,264 @@
import { useAutomation } from '../useAutomation';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import * as automationHelper from 'dashboard/helper/automationHelper';
import {
customAttributes,
agents,
teams,
labels,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
campaigns,
contacts,
inboxes,
languages,
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('vue-i18n');
vi.mock('dashboard/helper/automationHelper');
describe('useAutomation', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
'attributes/getAttributes': { value: customAttributes },
'attributes/getAttributesByModel': {
value: model => {
return model === 'conversation_attribute'
? [{ id: 1, name: 'Conversation Attribute' }]
: [{ id: 2, name: 'Contact Attribute' }];
},
},
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
'labels/getLabels': labels,
'teams/getTeams': teams,
'sla/getSLA': slaPolicies,
};
return { value: getterMap[getter] };
});
useI18n.mockReturnValue({ t: key => key });
useAlert.mockReturnValue(vi.fn());
// Mock getConditionOptions for different types
automationHelper.getConditionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'status':
return statusFilterOptions;
case 'team_id':
return teams;
case 'assignee_id':
return agents;
case 'contact':
return contacts;
case 'inbox_id':
return inboxes;
case 'campaigns':
return campaigns;
case 'browser_language':
return languages;
case 'country_code':
return countries;
case 'message_type':
return messageTypeOptions;
case 'priority':
return priorityOptions;
default:
return [];
}
});
// Mock getActionOptions for different types
automationHelper.getActionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'add_label':
return labels;
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
case 'send_email_to_team':
return teams;
case 'send_message':
return [];
case 'add_sla':
return slaPolicies;
case 'change_priority':
return priorityOptions;
default:
return [];
}
});
});
it('initializes computed properties correctly', () => {
const {
agents: computedAgents,
campaigns: computedCampaigns,
contacts: computedContacts,
inboxes: computedInboxes,
labels: computedLabels,
teams: computedTeams,
slaPolicies: computedSlaPolicies,
} = useAutomation();
expect(computedAgents.value).toEqual(agents);
expect(computedCampaigns.value).toEqual(campaigns);
expect(computedContacts.value).toEqual(contacts);
expect(computedInboxes.value).toEqual(inboxes);
expect(computedLabels.value).toEqual(labels);
expect(computedTeams.value).toEqual(teams);
expect(computedSlaPolicies.value).toEqual(slaPolicies);
});
it('appends new condition and action correctly', () => {
const { appendNewCondition, appendNewAction, automation } = useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
appendNewCondition();
appendNewAction();
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
});
it('removes filter and action correctly', () => {
const { removeFilter, removeAction, automation } = useAutomation();
automation.value = {
conditions: [{ id: 1 }, { id: 2 }],
actions: [{ id: 1 }, { id: 2 }],
};
removeFilter(0);
removeAction(0);
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
expect(automation.value.conditions[0].id).toBe(2);
expect(automation.value.actions[0].id).toBe(2);
});
it('resets filter and action correctly', () => {
const { resetFilter, resetAction, automation, automationTypes } =
useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: 'open',
},
],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
automationTypes.message_created = {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
};
resetFilter(0, automation.value.conditions[0]);
resetAction(0);
expect(automation.value.conditions[0].filter_operator).toBe('not_equal_to');
expect(automation.value.conditions[0].values).toBe('');
expect(automation.value.actions[0].action_params).toEqual([]);
});
it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes, automationTypes } = useAutomation();
automationTypes.message_created = { conditions: [] };
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
manifestCustomAttributes();
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
2
);
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
Object.values(automationTypes).forEach(type => {
expect(type.conditions).toHaveLength(0);
});
});
it('gets condition dropdown values correctly', () => {
const { getConditionDropdownValues } = useAutomation();
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
expect(getConditionDropdownValues('team_id')).toEqual(teams);
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
expect(getConditionDropdownValues('contact')).toEqual(contacts);
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
it('gets action dropdown values correctly', () => {
const { getActionDropdownValues } = useAutomation();
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
});
it('handles event change correctly', () => {
const { onEventChange, automation } = useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
onEventChange();
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
});
});

View File

@@ -0,0 +1,172 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: {
TEST_EVENT: 'captain_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import { useConfig } from '../useConfig';
describe('useConfig', () => {
const originalChatwootConfig = window.chatwootConfig;
beforeEach(() => {
window.chatwootConfig = {
hostURL: 'https://example.com',
vapidPublicKey: 'vapid-key',
enabledLanguages: ['en', 'fr'],
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
};
});
afterEach(() => {
window.chatwootConfig = originalChatwootConfig;
});
it('returns the correct configuration values', () => {
const config = useConfig();
expect(config.hostURL).toBe('https://example.com');
expect(config.vapidPublicKey).toBe('vapid-key');
expect(config.enabledLanguages).toEqual(['en', 'fr']);
expect(config.isEnterprise).toBe(true);
expect(config.enterprisePlanName).toBe('enterprise');
});
it('handles missing configuration values', () => {
window.chatwootConfig = {};
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
it('handles undefined window.chatwootConfig', () => {
window.chatwootConfig = undefined;
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
});

View File

@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});

View File

@@ -0,0 +1,348 @@
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
const defaultAttributes = [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'category',
attributeDisplayName: 'Category',
attributeDisplayType: 'text',
attributeValues: [],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
];
describe('useConversationRequiredAttributes', () => {
beforeEach(() => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => true };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: defaultAttributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: [
'priority',
'category',
'is_urgent',
],
},
},
},
accountId: { value: 1 },
});
});
const setupMocks = (
requiredAttributes = ['priority', 'category', 'is_urgent'],
{ attributes = defaultAttributes, featureEnabled = true } = {}
) => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => featureEnabled };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: attributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: requiredAttributes,
},
},
},
accountId: { value: 1 },
});
};
describe('requiredAttributeKeys', () => {
it('should return required attribute keys from account settings', () => {
setupMocks();
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([
'priority',
'category',
'is_urgent',
]);
});
it('should return empty array when no required attributes configured', () => {
setupMocks([]);
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
it('should return empty array when account settings is null', () => {
setupMocks([], { attributes: [] });
useAccount.mockReturnValue({
currentAccount: { value: { settings: null } },
accountId: { value: 1 },
});
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
});
describe('requiredAttributes', () => {
it('should return full attribute definitions for required attributes only', () => {
setupMocks();
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(3);
expect(requiredAttributes.value[0]).toEqual({
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
value: 'priority',
label: 'Priority',
type: 'list',
});
});
it('should filter out deleted attributes that no longer exist', () => {
// Mock with only 2 attributes available but 3 required
setupMocks(['priority', 'category', 'is_urgent'], {
attributes: [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
],
});
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(2);
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
'priority',
'is_urgent',
]);
});
});
describe('checkMissingAttributes', () => {
beforeEach(() => {
setupMocks();
});
it('should return no missing when all attributes are filled', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
expect(result.all).toHaveLength(3);
});
it('should detect missing text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
is_urgent: true,
// category is missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should detect empty string values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: '', // empty string
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
it('should consider checkbox attribute present when value is true', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should consider checkbox attribute present when value is false', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: false, // false is still considered "filled"
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should detect missing checkbox when key does not exist', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
// is_urgent key is completely missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('is_urgent');
expect(result.missing[0].type).toBe('checkbox');
});
it('should handle falsy values correctly for non-checkbox attributes', () => {
setupMocks(['score', 'status_flag'], {
attributes: [
{
attributeKey: 'score',
attributeDisplayName: 'Score',
attributeDisplayType: 'number',
attributeValues: [],
},
{
attributeKey: 'status_flag',
attributeDisplayName: 'Status Flag',
attributeDisplayType: 'text',
attributeValues: [],
},
],
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
score: 0, // zero should be considered valid, not missing
status_flag: false, // false should be considered valid, not missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle null values as missing for text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: null, // null should be missing for text attribute
is_urgent: true, // checkbox is present
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should consider undefined checkbox values as present when key exists', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should return no missing when no attributes are required', () => {
setupMocks([]); // No required attributes
const { checkMissingAttributes } = useConversationRequiredAttributes();
const result = checkMissingAttributes({});
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle whitespace-only values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: ' ', // whitespace only
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
});
});

View File

@@ -0,0 +1,101 @@
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
describe('useDetectKeyboardLayout', () => {
beforeEach(() => {
window.cw_keyboard_layout = null;
});
it('returns cached layout if available', async () => {
window.cw_keyboard_layout = LAYOUT_QWERTY;
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTZ layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'z'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTZ);
});
it('should detect AZERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'a'],
['KeyW', 'z'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_AZERTY);
});
it('should use legacy method if navigator.keyboard is not available', async () => {
navigator.keyboard = undefined;
const layout = await useDetectKeyboardLayout();
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
});
it('should cache the detected layout', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
const layoutAgain = await useDetectKeyboardLayout();
expect(layoutAgain).toBe(LAYOUT_QWERTY);
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,183 @@
import { useFileUpload } from '../useFileUpload';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper', () => ({
checkFileSizeLimit: vi.fn(),
resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40),
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40,
}));
vi.mock('@chatwoot/utils');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
const mockTranslate = vi.fn();
const mockFile = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
const inbox = {
channel_type: 'Channel::WhatsApp',
medium: 'whatsapp',
};
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: true, maximumFileUploadSize: 40 },
},
};
return getterMap[getter];
});
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
});
it('handles direct file upload when direct uploads enabled', () => {
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
const mockBlob = { signed_id: 'test-blob' };
DirectUpload.mockImplementation(() => ({
create: callback => callback(null, mockBlob),
}));
onFileUpload(mockFile);
// size rules called with inbox + mime
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
// size check called with max from helper
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
expect.any(Object)
);
expect(mockAttachFile).toHaveBeenCalledWith({
file: mockFile,
blob: mockBlob,
});
});
it('handles indirect file upload when direct upload disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: false, maximumFileUploadSize: 40 },
},
};
return getterMap[getter];
});
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('shows alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('uses per-mime limits from helper', () => {
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
mime.startsWith('image/') ? 10 : 50
);
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
DirectUpload.mockImplementation(() => ({
create: cb => cb(null, { signed_id: 'blob' }),
}));
onFileUpload(mockFile);
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
});
it('handles direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith(mockError);
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('does nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,165 @@
import { ref } from 'vue';
import { useFontSize } from '../useFontSize';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
// Mock dependencies
vi.mock('dashboard/composables/useUISettings');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn(cb => cb());
describe('useFontSize', () => {
const mockUISettings = ref({
font_size: '16px',
});
const mockUpdateUISettings = vi.fn().mockResolvedValue(undefined);
const mockTranslate = vi.fn(key => key);
beforeEach(() => {
vi.clearAllMocks();
// Setup mocks
useUISettings.mockReturnValue({
uiSettings: mockUISettings,
updateUISettings: mockUpdateUISettings,
});
useI18n.mockReturnValue({
t: mockTranslate,
});
// Reset DOM state
document.documentElement.style.removeProperty('font-size');
// Reset mockUISettings to default
mockUISettings.value = { font_size: '16px' };
});
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions.value).toHaveLength(5);
expect(fontSizeOptions.value[0]).toHaveProperty('value');
expect(fontSizeOptions.value[0]).toHaveProperty('label');
// Check specific options
expect(
fontSizeOptions.value.find(option => option.value === '16px')
).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
expect(
fontSizeOptions.value.find(option => option.value === '14px')
).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
});
it('returns currentFontSize from UI settings', () => {
const { currentFontSize } = useFontSize();
expect(currentFontSize.value).toBe('16px');
mockUISettings.value.font_size = '18px';
expect(currentFontSize.value).toBe('18px');
});
it('applies font size to document root correctly based on pixel values', () => {
const { applyFontSize } = useFontSize();
applyFontSize('18px');
expect(document.documentElement.style.fontSize).toBe('18px');
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('updates UI settings and applies font size', async () => {
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(mockUpdateUISettings).toHaveBeenCalledWith({ font_size: '20px' });
expect(document.documentElement.style.fontSize).toBe('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS'
);
});
it('shows error alert when update fails', async () => {
mockUpdateUISettings.mockRejectedValueOnce(new Error('Update failed'));
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR'
);
});
it('handles unknown font size values gracefully', () => {
const { applyFontSize } = useFontSize();
// Should not throw an error and should apply the default font size
applyFontSize('unknown-size');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('watches for UI settings changes and applies font size', async () => {
useFontSize();
// Initial font size should now be 16px instead of empty
expect(document.documentElement.style.fontSize).toBe('16px');
// Update UI settings
mockUISettings.value = { font_size: '18px' };
// Wait for next tick to let watchers fire
await Promise.resolve();
expect(document.documentElement.style.fontSize).toBe('18px');
});
it('translates font size option labels correctly', () => {
// Set up specific translation mapping
mockTranslate.mockImplementation(key => {
const translations = {
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER':
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
};
return translations[key] || key;
});
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
expect(
fontSizeOptions.value.find(option => option.value === '14px').label
).toBe('Smaller');
expect(
fontSizeOptions.value.find(option => option.value === '16px').label
).toBe('Default');
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER'
);
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT'
);
});
});

View File

@@ -0,0 +1,141 @@
import { ref } from 'vue';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
describe('useImageZoom', () => {
let imageRef;
beforeEach(() => {
// Mock imageRef element with getBoundingClientRect method
imageRef = ref({
getBoundingClientRect: () => ({
left: 100,
top: 100,
width: 200,
height: 200,
}),
});
});
it('should initialize with default values', () => {
const { zoomScale, imgTransformOriginPoint, activeImageRotation } =
useImageZoom(imageRef);
expect(zoomScale.value).toBe(1);
expect(imgTransformOriginPoint.value).toBe('center center');
expect(activeImageRotation.value).toBe(0);
});
it('should update zoom scale when onZoom is called', () => {
const { zoomScale, onZoom } = useImageZoom(imageRef);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
// Should respect max zoom level
onZoom(10);
expect(zoomScale.value).toBe(3);
// Should respect min zoom level
onZoom(-10);
expect(zoomScale.value).toBe(1);
});
it('should update rotation when onRotate is called', () => {
const { activeImageRotation, onRotate } = useImageZoom(imageRef);
onRotate('clockwise');
expect(activeImageRotation.value).toBe(90);
onRotate('counter-clockwise');
expect(activeImageRotation.value).toBe(0);
// Test full rotation reset
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
// After 360 degrees, it should reset and add the new rotation
expect(activeImageRotation.value).toBe(90);
});
it('should reset zoom and rotation', () => {
const {
zoomScale,
activeImageRotation,
onZoom,
onRotate,
resetZoomAndRotation,
} = useImageZoom(imageRef);
onZoom(0.5);
onRotate('clockwise');
expect(zoomScale.value).toBe(1); // Rotation resets zoom
expect(activeImageRotation.value).toBe(90);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
resetZoomAndRotation();
expect(zoomScale.value).toBe(1);
expect(activeImageRotation.value).toBe(0);
});
it('should handle double click zoom', () => {
const { zoomScale, onDoubleClickZoomImage } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
preventDefault: vi.fn(),
};
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(3); // Max zoom
expect(event.preventDefault).toHaveBeenCalled();
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(1); // Min zoom
});
it('should handle wheel zoom', () => {
const { zoomScale, onWheelImageZoom } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
deltaY: -10, // Zoom in
preventDefault: vi.fn(),
};
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1.2);
expect(event.preventDefault).toHaveBeenCalled();
// Zoom out
event.deltaY = 10;
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1);
});
it('should correctly compute zoom origin', () => {
const { getZoomOrigin } = useImageZoom(imageRef);
// Test center point
const centerOrigin = getZoomOrigin(200, 200);
expect(centerOrigin.x).toBeCloseTo(50);
expect(centerOrigin.y).toBeCloseTo(50);
// Test top-left corner
const topLeftOrigin = getZoomOrigin(100, 100);
expect(topLeftOrigin.x).toBeCloseTo(0);
expect(topLeftOrigin.y).toBeCloseTo(0);
// Test bottom-right corner
const bottomRightOrigin = getZoomOrigin(300, 300);
expect(bottomRightOrigin.x).toBeCloseTo(100);
expect(bottomRightOrigin.y).toBeCloseTo(100);
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useImpersonation } from '../useImpersonation';
vi.mock('shared/helpers/sessionStorage', () => ({
__esModule: true,
default: {
get: vi.fn(),
set: vi.fn(),
},
}));
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
describe('useImpersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if impersonation flag is set in session storage', () => {
SessionStorage.get.mockReturnValue(true);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(true);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
it('should return false if impersonation flag is not set in session storage', () => {
SessionStorage.get.mockReturnValue(false);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(false);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
});

View File

@@ -0,0 +1,285 @@
import { describe, it, expect, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useInbox } from '../useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useTransformKeys');
// Mock the dependencies
const mockStore = createStore({
modules: {
conversations: {
namespaced: false,
getters: {
getSelectedChat: () => ({ inbox_id: 1 }),
},
},
inboxes: {
namespaced: true,
getters: {
getInboxById: () => id => {
const inboxes = {
1: {
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
},
2: { id: 2, channel_type: INBOX_TYPES.FB },
3: { id: 3, channel_type: INBOX_TYPES.TWILIO, medium: 'sms' },
4: { id: 4, channel_type: INBOX_TYPES.TWILIO, medium: 'whatsapp' },
5: {
id: 5,
channel_type: INBOX_TYPES.EMAIL,
provider: 'microsoft',
},
6: { id: 6, channel_type: INBOX_TYPES.EMAIL, provider: 'google' },
7: {
id: 7,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'default',
},
8: { id: 8, channel_type: INBOX_TYPES.TELEGRAM },
9: { id: 9, channel_type: INBOX_TYPES.LINE },
10: { id: 10, channel_type: INBOX_TYPES.WEB },
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
};
return inboxes[id] || null;
},
},
},
},
});
// Mock useMapGetter to return mock store getters
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(getter => {
if (getter === 'getSelectedChat') {
return { value: { inbox_id: 1 } };
}
if (getter === 'inboxes/getInboxById') {
return { value: mockStore.getters['inboxes/getInboxById'] };
}
return { value: null };
}),
}));
// Mock useCamelCase to return the data as-is for testing
vi.mock('dashboard/composables/useTransformKeys', () => ({
useCamelCase: vi.fn(data => ({
...data,
channelType: data?.channel_type,
})),
}));
describe('useInbox', () => {
const createTestComponent = inboxId =>
defineComponent({
setup() {
return useInbox(inboxId);
},
render() {
return h('div');
},
});
describe('with current chat context (no inboxId provided)', () => {
it('identifies WhatsApp Cloud channel correctly', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
it('returns correct inbox object', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox).toEqual({
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
channelType: INBOX_TYPES.WHATSAPP,
});
});
});
describe('with explicit inboxId provided', () => {
it('identifies Facebook inbox correctly', () => {
const wrapper = mount(createTestComponent(2), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAFacebookInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio SMS channel correctly', () => {
const wrapper = mount(createTestComponent(3), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isASmsInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(4), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies Microsoft email inbox correctly', () => {
const wrapper = mount(createTestComponent(5), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(false);
});
it('identifies Google email inbox correctly', () => {
const wrapper = mount(createTestComponent(6), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(false);
});
it('identifies 360Dialog WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(7), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies all other channel types correctly', () => {
// Test Telegram
let wrapper = mount(createTestComponent(8), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATelegramChannel).toBe(true);
// Test Line
wrapper = mount(createTestComponent(9), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isALineChannel).toBe(true);
// Test Web Widget
wrapper = mount(createTestComponent(10), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
// Test API
wrapper = mount(createTestComponent(11), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAPIInbox).toBe(true);
// Test SMS
wrapper = mount(createTestComponent(12), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isASmsInbox).toBe(true);
// Test Instagram
wrapper = mount(createTestComponent(13), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
// Test Tiktok
wrapper = mount(createTestComponent(15), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATiktokChannel).toBe(true);
});
});
describe('edge cases', () => {
it('handles non-existent inbox ID gracefully', () => {
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
// useCamelCase still processes null data, so we get an object with channelType: undefined
expect(wrapper.vm.inbox).toEqual({ channelType: undefined });
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
});
it('handles inbox with no data correctly', () => {
// The mock will return null for non-existent IDs, but useCamelCase processes it
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox.channelType).toBeUndefined();
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
expect(wrapper.vm.isATelegramChannel).toBe(false);
});
});
describe('return object completeness', () => {
it('returns all expected properties', () => {
const wrapper = mount(createTestComponent(1), {
global: { plugins: [mockStore] },
});
const expectedProperties = [
'inbox',
'isAFacebookInbox',
'isALineChannel',
'isAPIInbox',
'isASmsInbox',
'isATelegramChannel',
'isATwilioChannel',
'isAWebWidgetInbox',
'isAWhatsAppChannel',
'isAMicrosoftInbox',
'isAGoogleInbox',
'isATwilioWhatsAppChannel',
'isAWhatsAppCloudChannel',
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isATiktokChannel',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
expect(wrapper.vm).toHaveProperty(prop);
});
});
});
});

View File

@@ -0,0 +1,56 @@
import { useIntegrationHook } from '../useIntegrationHook';
import { useMapGetter } from 'dashboard/composables/store';
import { nextTick } from 'vue';
vi.mock('dashboard/composables/store');
describe('useIntegrationHook', () => {
let integrationGetter;
beforeEach(() => {
integrationGetter = vi.fn();
useMapGetter.mockReturnValue({ value: integrationGetter });
});
it('should return the correct computed properties', async () => {
const mockIntegration = {
id: 1,
hook_type: 'inbox',
hooks: ['hook1', 'hook2'],
allow_multiple_hooks: true,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(1);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('multiple');
expect(hook.isIntegrationMultiple.value).toBe(true);
expect(hook.isIntegrationSingle.value).toBe(false);
expect(hook.isHookTypeInbox.value).toBe(true);
expect(hook.hasConnectedHooks.value).toBe(true);
});
it('should handle single integration type correctly', async () => {
const mockIntegration = {
id: 2,
hook_type: 'channel',
hooks: [],
allow_multiple_hooks: false,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(2);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('single');
expect(hook.isIntegrationMultiple.value).toBe(false);
expect(hook.isIntegrationSingle.value).toBe(true);
expect(hook.isHookTypeInbox.value).toBe(false);
expect(hook.hasConnectedHooks.value).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
it('should return a function', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { useKeyboardNavigableList } from '../useKeyboardNavigableList';
import { useKeyboardEvents } from '../useKeyboardEvents';
// Mock the useKeyboardEvents function
vi.mock('../useKeyboardEvents', () => ({
useKeyboardEvents: vi.fn(),
}));
describe('useKeyboardNavigableList', () => {
let items;
let onSelect;
let adjustScroll;
let selectedIndex;
const createMockEvent = () => ({ preventDefault: vi.fn() });
beforeEach(() => {
items = ref(['item1', 'item2', 'item3']);
onSelect = vi.fn();
adjustScroll = vi.fn();
selectedIndex = ref(0);
vi.clearAllMocks();
});
it('should return moveSelectionUp and moveSelectionDown functions', () => {
const result = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(result).toHaveProperty('moveSelectionUp');
expect(result).toHaveProperty('moveSelectionDown');
expect(typeof result.moveSelectionUp).toBe('function');
expect(typeof result.moveSelectionDown).toBe('function');
});
it('should move selection up correctly', () => {
const { moveSelectionUp } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
moveSelectionUp();
expect(selectedIndex.value).toBe(1);
moveSelectionUp();
expect(selectedIndex.value).toBe(0);
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
});
it('should move selection down correctly', () => {
const { moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
moveSelectionDown();
expect(selectedIndex.value).toBe(2);
moveSelectionDown();
expect(selectedIndex.value).toBe(0);
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
});
it('should call adjustScroll after moving selection', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(adjustScroll).toHaveBeenCalledTimes(1);
moveSelectionDown();
expect(adjustScroll).toHaveBeenCalledTimes(2);
});
it('should include Enter key handler when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).toHaveProperty('Enter');
expect(keyboardEvents.Enter.allowOnFocusedInput).toBe(true);
});
it('should not include Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).not.toHaveProperty('Enter');
});
it('should not trigger onSelect when items are empty', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items: ref([]),
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
moveSelectionDown();
expect(onSelect).not.toHaveBeenCalled();
});
it('should call useKeyboardEvents with correct parameters', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
});
// Keyboard event handlers
it('should handle ArrowUp key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowUp.action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyP', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyP'].action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle ArrowDown key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowDown.action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyN', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyN'].action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Enter key when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.Enter.action(mockEvent);
expect(onSelect).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should not have Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEventsWithoutSelect = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEventsWithoutSelect).not.toHaveProperty('Enter');
});
it('should set allowOnFocusedInput to true for all key handlers', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const keyHandlers = [
'ArrowUp',
'Control+KeyP',
'ArrowDown',
'Control+KeyN',
'Enter',
];
keyHandlers.forEach(key => {
if (keyboardEvents[key]) {
expect(keyboardEvents[key].allowOnFocusedInput).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
describe('useMacros', () => {
const mockLabels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
const mockTeams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
const mockAgents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail: 'https://example.com/image.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
beforeEach(() => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
});
});
it('initializes computed properties correctly', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
const { getMacroDropdownValues } = useMacros();
const expectedLabels = mockLabels.map(i => ({
id: i.title,
name: i.title,
}));
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
});
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
}));
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
});
it('returns an empty array for unknown types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
});
it('handles empty data correctly', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
]);
});
});

View File

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

View File

@@ -0,0 +1,31 @@
import { selectTranslation } from '../useTranslations';
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
});
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
});
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
});
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
});
});

View File

@@ -0,0 +1,163 @@
import { ref } from 'vue';
import {
useUISettings,
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
} from 'dashboard/composables/useUISettings';
// Mocking the store composables
const mockDispatch = vi.fn();
const getUISettingsMock = ref({
is_ct_labels_open: true,
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({
getUISettings: getUISettingsMock,
}),
useStore: () => ({
dispatch: mockDispatch,
}),
}));
describe('useUISettings', () => {
beforeEach(() => {
mockDispatch.mockClear();
});
it('returns uiSettings', () => {
const { uiSettings } = useUISettings();
expect(uiSettings.value).toEqual({
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
});
it('updates UI settings correctly', () => {
const { updateUISettings } = useUISettings();
updateUISettings({ enter_to_send_enabled: true });
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
enter_to_send_enabled: true,
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('toggles sidebar UI state correctly', () => {
const { toggleSidebarUIState } = useUISettings();
toggleSidebarUIState('is_ct_labels_open');
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: false,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('returns correct conversation sidebar items order', () => {
const { conversationSidebarItemsOrder } = useUISettings();
expect(conversationSidebarItemsOrder.value).toEqual(
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER
);
});
it('returns correct contact sidebar items order', () => {
const { contactSidebarItemsOrder } = useUISettings();
expect(contactSidebarItemsOrder.value).toEqual(
DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER
);
});
it('returns correct value for isContactSidebarItemOpen', () => {
const { isContactSidebarItemOpen } = useUISettings();
expect(isContactSidebarItemOpen('is_ct_labels_open')).toBe(true);
expect(isContactSidebarItemOpen('non_existent_key')).toBe(false);
});
it('sets signature flag for inbox correctly', () => {
const { setSignatureFlagForInbox } = useUISettings();
setSignatureFlagForInbox('email', true);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('fetches signature flag from UI settings correctly', () => {
const { fetchSignatureFlagFromUISettings } = useUISettings();
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
});
it('sets quoted reply flag for inbox correctly', () => {
const { setQuotedReplyFlagForInbox } = useUISettings();
setQuotedReplyFlagForInbox('Channel::Email', false);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: false,
},
});
});
it('fetches quoted reply flag from UI settings correctly', () => {
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
getUISettingsMock.value.enter_to_send_enabled = false;
const { isEditorHotKeyEnabled } = useUISettings();
expect(isEditorHotKeyEnabled('enter')).toBe(true);
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(false);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is not configured', () => {
getUISettingsMock.value.editor_message_key = undefined;
const { isEditorHotKeyEnabled } = useUISettings();
expect(isEditorHotKeyEnabled('enter')).toBe(false);
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(true);
});
it('handles non-existent keys', () => {
const {
isContactSidebarItemOpen,
fetchSignatureFlagFromUISettings,
isEditorHotKeyEnabled,
} = useUISettings();
expect(isContactSidebarItemOpen('non_existent_key')).toBe(false);
expect(fetchSignatureFlagFromUISettings('non_existent_key')).toBe(
undefined
);
expect(isEditorHotKeyEnabled('non_existent_key')).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
import { computed, unref } from 'vue';
import { getCurrentInstance } from 'vue';
export const useStore = () => {
const vm = getCurrentInstance();
if (!vm) throw new Error('must be called in setup');
return vm.proxy.$store;
};
export const useStoreGetters = () => {
const store = useStore();
return Object.fromEntries(
Object.keys(store.getters).map(getter => [
getter,
computed(() => store.getters[getter]),
])
);
};
export const useMapGetter = key => {
const store = useStore();
return computed(() => store.getters[key]);
};
export const useFunctionGetter = (key, ...args) => {
const store = useStore();
return computed(() => {
const unrefedArgs = args.map(arg => unref(arg));
return store.getters[key](...unrefedArgs);
});
};

View File

@@ -0,0 +1,65 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from './store';
/**
* Composable for account-related operations.
* @returns {Object} An object containing account-related properties and methods.
*/
export function useAccount() {
/**
* Computed property for the current account ID.
* @type {import('vue').ComputedRef<number>}
*/
const route = useRoute();
const store = useStore();
const getAccountFn = useMapGetter('accounts/getAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const accountId = computed(() => {
return Number(route.params.accountId);
});
const currentAccount = computed(() => getAccountFn.value(accountId.value));
/**
* Generates an account-scoped URL.
* @param {string} url - The URL to be scoped to the account.
* @returns {string} The account-scoped URL.
*/
const accountScopedUrl = url => {
return `/app/accounts/${accountId.value}/${url}`;
};
const isCloudFeatureEnabled = feature => {
return isFeatureEnabledonAccount.value(currentAccount.value.id, feature);
};
const accountScopedRoute = (name, params, query) => {
return {
name,
params: { accountId: accountId.value, ...params },
query: { ...query },
};
};
const updateAccount = async (data, options) => {
await store.dispatch('accounts/update', {
...data,
options,
});
};
return {
accountId,
route,
currentAccount,
accountScopedUrl,
accountScopedRoute,
isCloudFeatureEnabled,
isOnChatwootCloud,
updateAccount,
};
}

View File

@@ -0,0 +1,17 @@
import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
/**
* Composable to determine if the current user is an administrator.
* @returns {Boolean} - True if the current user is an administrator, false otherwise.
*/
export function useAdmin() {
const getters = useStoreGetters();
const currentUserRole = computed(() => getters.getCurrentRole.value);
const isAdmin = computed(() => currentUserRole.value === 'administrator');
return {
isAdmin,
};
}

View File

@@ -0,0 +1,70 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import {
getAgentsByUpdatedPresence,
getSortedAgentsByAvailability,
} from 'dashboard/helper/agentHelper';
/**
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
/**
* Creates a 'None' agent object
* @returns {Object} None agent object
*/
const createNoneAgent = () => ({
confirmed: true,
name: t('AGENT_MGMT.MULTI_SELECTOR.LIST.NONE') || 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
currentAccountId.value
);
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
agentsByUpdatedPresence
);
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
];
});
return {
agentsList,
assignableAgents,
};
}

View File

@@ -0,0 +1,209 @@
import { ref, reactive, computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import {
generateCustomAttributeTypes,
getDefaultConditions,
getDefaultActions,
generateCustomAttributes,
} from 'dashboard/helper/automationHelper';
import useAutomationValues from './useAutomationValues';
import {
// AUTOMATION_RULE_EVENTS,
// AUTOMATION_ACTION_TYPES,
AUTOMATIONS,
} from 'dashboard/routes/dashboard/settings/automation/constants.js';
/**
* Composable for handling automation-related functionality.
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export function useAutomation(startValue = null) {
const getters = useStoreGetters();
const { t } = useI18n();
const {
booleanFilterOptions,
statusFilterOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
} = useAutomationValues();
const automation = ref(startValue);
const automationTypes = reactive(structuredClone(AUTOMATIONS));
const eventName = computed(() => automation.value?.event_name);
/**
* Handles the event change for an automation.value.
*/
const onEventChange = () => {
automation.value.conditions = getDefaultConditions(eventName.value);
automation.value.actions = getDefaultActions();
};
/**
* Appends a new condition to the automation.value.
*/
const appendNewCondition = () => {
const defaultCondition = getDefaultConditions(eventName.value);
automation.value.conditions = [
...automation.value.conditions,
...defaultCondition,
];
};
/**
* Appends a new action to the automation.value.
*/
const appendNewAction = () => {
const defaultAction = getDefaultActions();
automation.value.actions = [...automation.value.actions, ...defaultAction];
};
/**
* Removes a filter from the automation.value.
* @param {number} index - The index of the filter to remove.
*/
const removeFilter = index => {
if (automation.value.conditions.length <= 1) {
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
} else {
automation.value.conditions = automation.value.conditions.filter(
(_, i) => i !== index
);
}
};
/**
* Removes an action from the automation.value.
* @param {number} index - The index of the action to remove.
*/
const removeAction = index => {
if (automation.value.actions.length <= 1) {
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
} else {
automation.value.actions = automation.value.actions.filter(
(_, i) => i !== index
);
}
};
/**
* Resets a filter in the automation.value.
* @param {Object} automationTypes - The automation types object.
* @param {number} index - The index of the filter to reset.
* @param {Object} currentCondition - The current condition object.
*/
const resetFilter = (index, currentCondition) => {
const newConditions = [...automation.value.conditions];
newConditions[index] = {
...newConditions[index],
filter_operator: automationTypes[eventName.value].conditions.find(
condition => condition.key === currentCondition.attribute_key
).filterOperators[0].value,
values: '',
};
automation.value.conditions = newConditions;
};
/**
* Resets an action in the automation.value.
* @param {number} index - The index of the action to reset.
*/
const resetAction = index => {
const newActions = [...automation.value.actions];
newActions[index] = {
...newActions[index],
action_params: [],
};
automation.value.actions = newActions;
};
/**
* This function formats the custom attributes for automation types.
* It retrieves custom attributes for conversations and contacts,
* generates custom attribute types, and adds them to the relevant automation types.
*/
const manifestCustomAttributes = () => {
const conversationCustomAttributesRaw = getters[
'attributes/getAttributesByModel'
].value('conversation_attribute');
const contactCustomAttributesRaw =
getters['attributes/getAttributesByModel'].value('contact_attribute');
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
conversationCustomAttributesRaw,
'conversation_attribute'
);
const contactCustomAttributeTypes = generateCustomAttributeTypes(
contactCustomAttributesRaw,
'contact_attribute'
);
const manifestedCustomAttributes = generateCustomAttributes(
conversationCustomAttributeTypes,
contactCustomAttributeTypes,
t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
);
const CUSTOM_ATTR_HEADER_KEYS = new Set([
'conversation_custom_attribute',
'contact_custom_attribute',
]);
[
'message_created',
'conversation_created',
'conversation_updated',
'conversation_opened',
].forEach(eventToUpdate => {
const standardConditions = automationTypes[
eventToUpdate
].conditions.filter(
c => !c.customAttributeType && !CUSTOM_ATTR_HEADER_KEYS.has(c.key)
);
automationTypes[eventToUpdate].conditions = [
...standardConditions,
...manifestedCustomAttributes,
];
});
};
return {
automation,
automationTypes,
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
booleanFilterOptions,
statusFilterOptions,
onEventChange,
getConditionDropdownValues,
appendNewCondition,
appendNewAction,
removeFilter,
removeAction,
resetFilter,
resetAction,
getActionDropdownValues,
manifestCustomAttributes,
};
}

View File

@@ -0,0 +1,152 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import {
getActionOptions,
getConditionOptions,
} from 'dashboard/helper/automationHelper';
import {
MESSAGE_CONDITION_VALUES,
PRIORITY_CONDITION_VALUES,
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilities used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
const labels = useMapGetter('labels/getLabels');
const teams = useMapGetter('teams/getTeams');
const slaPolicies = useMapGetter('sla/getSLA');
const booleanFilterOptions = computed(() => [
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
]);
const statusFilterItems = computed(() => {
return {
open: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT'),
},
resolved: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.resolved.TEXT'),
},
pending: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.pending.TEXT'),
},
snoozed: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.snoozed.TEXT'),
},
all: {
TEXT: t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.all.TEXT'),
},
};
});
const statusFilterOptions = computed(() => {
const statusFilters = statusFilterItems.value;
return [
...Object.keys(statusFilters).map(status => ({
id: status,
name: statusFilters[status].TEXT,
})),
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
];
});
const messageTypeOptions = computed(() =>
MESSAGE_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.MESSAGE_TYPES.${item.i18nKey}`),
}))
);
const priorityOptions = computed(() =>
PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`AUTOMATION.PRIORITY_TYPES.${item.i18nKey}`),
}))
);
/**
* Adds a translated "None" option to the beginning of a list
* @param {Array} list - The list to add "None" to
* @returns {Array} A new array with "None" option at the beginning
*/
const addNoneToList = list => [
{
id: 'nil',
name: t('AUTOMATION.NONE_OPTION') || 'None',
},
...(list || []),
];
/**
* Gets the condition dropdown values for a given type.
* @param {string} type - The type of condition.
* @returns {Array} An array of condition dropdown values.
*/
const getConditionDropdownValues = type => {
return getConditionOptions({
agents: agents.value,
booleanFilterOptions: booleanFilterOptions.value,
campaigns: campaigns.value,
contacts: contacts.value,
customAttributes: getters['attributes/getAttributes'].value,
inboxes: inboxes.value,
labels: labels.value,
statusFilterOptions: statusFilterOptions.value,
priorityOptions: priorityOptions.value,
messageTypeOptions: messageTypeOptions.value,
teams: teams.value,
languages,
countries,
type,
});
};
/**
* Gets the action dropdown values for a given type.
* @param {string} type - The type of action.
* @returns {Array} An array of action dropdown values.
*/
const getActionDropdownValues = type => {
return getActionOptions({
agents: agents.value,
labels: labels.value,
teams: teams.value,
slaPolicies: slaPolicies.value,
languages,
type,
addNoneToListFn: addNoneToList,
priorityOptions: priorityOptions.value,
});
};
return {
booleanFilterOptions,
statusFilterItems,
statusFilterOptions,
priorityOptions,
messageTypeOptions,
getConditionDropdownValues,
getActionDropdownValues,
agents,
campaigns,
contacts,
inboxes,
labels,
teams,
slaPolicies,
};
}

View File

@@ -0,0 +1,110 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
callDuration.value = elapsed;
});
const activeCall = computed(() => callsStore.activeCall);
const incomingCalls = computed(() => callsStore.incomingCalls);
const hasActiveCall = computed(() => callsStore.hasActiveCall);
watch(
hasActiveCall,
active => {
if (active) {
durationTimer.start();
} else {
durationTimer.stop();
callDuration.value = 0;
}
},
{ immediate: true }
);
onMounted(() => {
TwilioVoiceClient.addEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
onUnmounted(() => {
durationTimer.stop();
TwilioVoiceClient.removeEventListener('call:disconnected', () =>
callsStore.clearActiveCall()
);
});
const endCall = async ({ conversationId, inboxId }) => {
await VoiceAPI.leaveConference(inboxId, conversationId);
TwilioVoiceClient.endClientCall();
durationTimer.stop();
callsStore.clearActiveCall();
};
const joinCall = async ({ conversationId, inboxId, callSid }) => {
if (isJoining.value) return null;
isJoining.value = true;
try {
const device = await TwilioVoiceClient.initializeDevice(inboxId);
if (!device) return null;
const joinResponse = await VoiceAPI.joinConference({
conversationId,
inboxId,
callSid,
});
await TwilioVoiceClient.joinClientCall({
to: joinResponse?.conference_sid,
conversationId,
});
callsStore.setCallActive(callSid);
durationTimer.start();
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
} finally {
isJoining.value = false;
}
};
const rejectIncomingCall = callSid => {
TwilioVoiceClient.endClientCall();
callsStore.dismissCall(callSid);
};
const dismissCall = callSid => {
callsStore.dismissCall(callSid);
};
const formattedCallDuration = computed(() => {
const minutes = Math.floor(callDuration.value / 60);
const seconds = callDuration.value % 60;
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
});
return {
activeCall,
incomingCalls,
hasActiveCall,
isJoining,
formattedCallDuration,
joinCall,
endCall,
rejectIncomingCall,
dismissCall,
};
}

View File

@@ -0,0 +1,246 @@
import { computed } from 'vue';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
import { CAPTAIN_ERROR_TYPES } from 'dashboard/composables/captain/constants';
export function useCaptain() {
const store = useStore();
const { t } = useI18n();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const uiFlags = useMapGetter('accounts/getUIFlags');
const currentChat = useMapGetter('getSelectedChat');
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
const conversationId = computed(() => currentChat.value?.id);
const draftKey = computed(
() => `draft-${conversationId.value}-${replyMode.value}`
);
const draftMessage = useFunctionGetter('draftMessages/get', draftKey);
// === Feature Flags ===
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
});
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
// === Limits (Enterprise) ===
const captainLimits = computed(() => {
return currentAccount.value?.limits?.captain;
});
const documentLimits = computed(() => {
if (captainLimits.value?.documents) {
return useCamelCase(captainLimits.value.documents);
}
return null;
});
const responseLimits = computed(() => {
if (captainLimits.value?.responses) {
return useCamelCase(captainLimits.value.responses);
}
return null;
});
const isFetchingLimits = computed(() => uiFlags.value.isFetchingLimits);
const fetchLimits = () => {
if (isEnterprise) {
store.dispatch('accounts/limits');
}
};
// === Error Handling ===
/**
* Handles API errors and displays appropriate error messages.
* Silently returns for aborted requests.
* @param {Error} error - The error object from the API call.
*/
const handleAPIError = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
const errorMessage =
error.response?.data?.error ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
};
/**
* Classifies API error types for downstream analytics.
* @param {Error} error
* @returns {string}
*/
const getErrorType = error => {
if (
error.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return CAPTAIN_ERROR_TYPES.ABORTED;
}
if (error.response?.status) {
return `${CAPTAIN_ERROR_TYPES.HTTP_PREFIX}${error.response.status}`;
}
return CAPTAIN_ERROR_TYPES.API_ERROR;
};
// === Task Methods ===
/**
* Rewrites content with a specific operation.
* @param {string} content - The content to rewrite.
* @param {string} operation - The operation (fix_spelling_grammar, casual, professional, expand, shorten, improve, etc).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The rewritten content and optional follow-up context.
*/
const rewriteContent = async (content, operation, options = {}) => {
try {
const result = await TasksAPI.rewrite(
{
content: content || draftMessage.value,
operation,
conversationId: conversationId.value,
},
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
}
};
/**
* Summarizes a conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The summary and optional follow-up context.
*/
const summarizeConversation = async (options = {}) => {
try {
const result = await TasksAPI.summarize(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
}
};
/**
* Gets a reply suggestion for the current conversation.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The reply suggestion and optional follow-up context.
*/
const getReplySuggestion = async (options = {}) => {
try {
const result = await TasksAPI.replySuggestion(
conversationId.value,
options.signal
);
const {
data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
return { message: generatedMessage, followUpContext };
} catch (error) {
handleAPIError(error);
return { message: '', errorType: getErrorType(error) };
}
};
/**
* Sends a follow-up message to refine a previous AI task result.
* @param {Object} options - The follow-up options.
* @param {Object} options.followUpContext - The follow-up context from a previous task.
* @param {string} options.message - The follow-up message/request from the user.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
*/
const followUp = async ({ followUpContext, message, signal }) => {
try {
const result = await TasksAPI.followUp(
{ followUpContext, message, conversationId: conversationId.value },
signal
);
const {
data: { message: generatedMessage, follow_up_context: updatedContext },
} = result;
return { message: generatedMessage, followUpContext: updatedContext };
} catch (error) {
handleAPIError(error);
return {
message: '',
followUpContext,
errorType: getErrorType(error),
};
}
};
/**
* Processes an AI event. Routes to the appropriate method based on type.
* @param {string} [type='improve'] - The type of AI event to process.
* @param {string} [content=''] - The content to process.
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
if (type === 'summarize') {
return summarizeConversation(options);
}
if (type === 'reply_suggestion') {
return getReplySuggestion(options);
}
// All other types are rewrite operations
return rewriteContent(content, type, options);
};
return {
// Feature flags
captainEnabled,
captainTasksEnabled,
// Limits (Enterprise)
captainLimits,
documentLimits,
responseLimits,
fetchLimits,
isFetchingLimits,
// Conversation context
draftMessage,
currentChat,
// Task methods
rewriteContent,
summarizeConversation,
getReplySuggestion,
followUp,
processEvent,
};
}

View File

@@ -0,0 +1,46 @@
/**
* A function that provides access to various configuration values.
* @returns {Object} An object containing configuration values.
*/
export function useConfig() {
const config = window.chatwootConfig || {};
/**
* The host URL of the Chatwoot instance.
* @type {string|undefined}
*/
const hostURL = config.hostURL;
/**
* The VAPID public key for web push notifications.
* @type {string|undefined}
*/
const vapidPublicKey = config.vapidPublicKey;
/**
* An array of enabled languages in the Chatwoot instance.
* @type {string[]|undefined}
*/
const enabledLanguages = config.enabledLanguages;
/**
* Indicates whether the current instance is an enterprise version.
* @type {boolean}
*/
const isEnterprise = config.isEnterprise === 'true';
/**
* The name of the enterprise plan, if applicable.
* Returns "community" or "enterprise"
* @type {string|undefined}
*/
const enterprisePlanName = config.enterprisePlanName;
return {
hostURL,
vapidPublicKey,
enabledLanguages,
isEnterprise,
enterprisePlanName,
};
}

View File

@@ -0,0 +1,101 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
/**
* Composable for managing conversation labels
* @returns {Object} An object containing methods and computed properties for conversation labels
*/
export function useConversationLabels() {
const store = useStore();
const getters = useStoreGetters();
/**
* The currently selected chat
* @type {import('vue').ComputedRef<Object>}
*/
const currentChat = computed(() => getters.getSelectedChat.value);
/**
* The ID of the current conversation
* @type {import('vue').ComputedRef<number|null>}
*/
const conversationId = computed(() => currentChat.value?.id);
/**
* All labels available for the account
* @type {import('vue').ComputedRef<Array>}
*/
const accountLabels = computed(() => getters['labels/getLabels'].value);
/**
* Labels currently saved to the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const savedLabels = computed(() => {
return store.getters['conversationLabels/getConversationLabels'](
conversationId.value
);
});
/**
* Labels currently active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const activeLabels = computed(() =>
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
);
/**
* Labels available but not active on the conversation
* @type {import('vue').ComputedRef<Array>}
*/
const inactiveLabels = computed(() =>
accountLabels.value.filter(
({ title }) => !savedLabels.value.includes(title)
)
);
/**
* Updates the labels for the current conversation
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
* @returns {Promise<void>}
*/
const onUpdateLabels = async selectedLabels => {
await store.dispatch('conversationLabels/update', {
conversationId: conversationId.value,
labels: selectedLabels,
});
};
/**
* Adds a label to the current conversation
* @param {Object} value - The label object to be added
* @param {string} value.title - The title of the label to be added
*/
const addLabelToConversation = value => {
const result = activeLabels.value.map(item => item.title);
result.push(value.title);
onUpdateLabels(result);
};
/**
* Removes a label from the current conversation
* @param {string} value - The title of the label to be removed
*/
const removeLabelFromConversation = value => {
const result = activeLabels.value
.map(label => label.title)
.filter(label => label !== value);
onUpdateLabels(result);
};
return {
accountLabels,
savedLabels,
activeLabels,
inactiveLabels,
addLabelToConversation,
removeLabelFromConversation,
onUpdateLabels,
};
}

View File

@@ -0,0 +1,97 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { ATTRIBUTE_TYPES } from 'dashboard/components-next/ConversationWorkflow/constants';
/**
* Composable for managing conversation required attributes workflow
*
* This handles the logic for checking if conversations have all required
* custom attributes filled before they can be resolved.
*/
export function useConversationRequiredAttributes() {
const { currentAccount, accountId } = useAccount();
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
const conversationAttributes = useMapGetter(
'attributes/getConversationAttributes'
);
const isFeatureEnabled = computed(() =>
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CONVERSATION_REQUIRED_ATTRIBUTES
)
);
const requiredAttributeKeys = computed(() => {
if (!isFeatureEnabled.value) return [];
return (
currentAccount.value?.settings?.conversation_required_attributes || []
);
});
const allAttributeOptions = computed(() =>
(conversationAttributes.value || []).map(attribute => ({
...attribute,
value: attribute.attributeKey,
label: attribute.attributeDisplayName,
type: attribute.attributeDisplayType,
attributeValues: attribute.attributeValues,
}))
);
/**
* Get the full attribute definitions for only the required attributes
* Filters allAttributeOptions to only include attributes marked as required
*/
const requiredAttributes = computed(
() =>
requiredAttributeKeys.value
.map(key =>
allAttributeOptions.value.find(attribute => attribute.value === key)
)
.filter(Boolean) // Remove any undefined attributes (deleted attributes)
);
/**
* Check if a conversation is missing any required attributes
*
* @param {Object} conversationCustomAttributes - Current conversation's custom attributes
* @returns {Object} - Analysis result with missing attributes info
*/
const checkMissingAttributes = (conversationCustomAttributes = {}) => {
// If no attributes are required, conversation can be resolved
if (!requiredAttributes.value.length) {
return { hasMissing: false, missing: [] };
}
// Find attributes that are missing or empty
const missing = requiredAttributes.value.filter(attribute => {
const value = conversationCustomAttributes[attribute.value];
// For checkbox/boolean attributes, only check if the key exists
if (attribute.type === ATTRIBUTE_TYPES.CHECKBOX) {
return !(attribute.value in conversationCustomAttributes);
}
// For other attribute types, only consider null, undefined, empty string, or whitespace-only as missing
// Allow falsy values like 0, false as they are valid filled values
return value == null || String(value).trim() === '';
});
return {
hasMissing: missing.length > 0,
missing,
all: requiredAttributes.value,
};
};
return {
requiredAttributeKeys,
requiredAttributes,
checkMissingAttributes,
};
}

View File

@@ -0,0 +1,373 @@
import { ref, computed } from 'vue';
import { useCaptain } from 'dashboard/composables/useCaptain';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import { CAPTAIN_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import {
CAPTAIN_ERROR_TYPES,
CAPTAIN_GENERATION_FAILURE_REASONS,
} from 'dashboard/composables/captain/constants';
// Actions that map to REWRITE events (with operation attribute)
const REWRITE_ACTIONS = [
'improve',
'fix_spelling_grammar',
'casual',
'professional',
'expand',
'shorten',
'rephrase',
'make_friendly',
'make_formal',
'simplify',
];
/**
* Gets the event key suffix based on action type.
* @param {string} action - The action type
* @returns {string} The event key prefix (REWRITE, SUMMARIZE, or REPLY_SUGGESTION)
*/
function getEventPrefix(action) {
if (action === 'summarize') return 'SUMMARIZE';
if (action === 'reply_suggestion') return 'REPLY_SUGGESTION';
return 'REWRITE';
}
/**
* Builds the analytics payload based on action type.
* @param {string} action - The action type
* @param {number} conversationId - The conversation ID
* @param {number} [followUpCount] - Optional follow-up count
* @returns {Object} The payload object
*/
function buildPayload(action, conversationId, followUpCount = undefined) {
const payload = { conversationId };
// Add operation for rewrite actions
if (REWRITE_ACTIONS.includes(action)) {
payload.operation = action;
}
// Add followUpCount if provided
if (followUpCount !== undefined) {
payload.followUpCount = followUpCount;
}
return payload;
}
function trackGenerationFailure({
action,
conversationId,
followUpCount = undefined,
stage,
reason,
}) {
useTrack(CAPTAIN_EVENTS.GENERATION_FAILED, {
...buildPayload(action, conversationId, followUpCount),
stage,
reason,
});
}
/**
* Composable for managing Copilot reply generation state and actions.
* Extracts copilot-related logic from ReplyBox for cleaner code organization.
*
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
const { processEvent, followUp, currentChat } = useCaptain();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
const followUpContext = ref(null);
const abortController = ref(null);
// Tracking state
const currentAction = ref(null);
const followUpCount = ref(0);
const trackedConversationId = ref(null);
const conversationId = computed(() => currentChat.value?.id);
const isActive = computed(() => showEditor.value || isGenerating.value);
const isButtonDisabled = computed(
() => isGenerating.value || !isContentReady.value
);
const editorTransitionKey = computed(() =>
isActive.value ? 'copilot' : 'rich'
);
/**
* Resets all copilot editor state and cancels any ongoing generation.
* @param {boolean} [trackDismiss=true] - Whether to track dismiss event
*/
function reset(trackDismiss = true) {
// Track dismiss event if there was content and we're not accepting
if (trackDismiss && generatedContent.value && currentAction.value) {
const eventKey = `${getEventPrefix(currentAction.value)}_DISMISSED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
)
);
}
if (abortController.value) {
abortController.value.abort();
abortController.value = null;
}
showEditor.value = false;
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
followUpContext.value = null;
currentAction.value = null;
followUpCount.value = 0;
trackedConversationId.value = null;
}
/**
* Toggles the copilot editor visibility.
*/
function toggleEditor() {
showEditor.value = !showEditor.value;
}
/**
* Marks content as ready (called after transition completes).
*/
function setContentReady() {
isContentReady.value = true;
}
/**
* Executes a copilot action (e.g., improve, fix grammar).
* @param {string} action - The action type
* @param {string} data - The content to process
*/
async function execute(action, data) {
if (action === 'ask_copilot') {
updateUISettings({
is_contact_sidebar_open: false,
is_copilot_panel_open: true,
});
return;
}
// Reset without tracking dismiss (starting new action)
reset(false);
const requestController = new AbortController();
abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
currentAction.value = action;
followUpCount.value = 0;
trackedConversationId.value = conversationId.value;
try {
const {
message: content,
followUpContext: newContext,
errorType,
} = await processEvent(action, data, {
signal: requestController.signal,
});
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
}
return;
}
generatedContent.value = content;
followUpContext.value = newContext;
if (content) {
showEditor.value = true;
// Track "Used" event on successful generation
const eventKey = `${getEventPrefix(action)}_USED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(action, trackedConversationId.value)
);
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: errorType,
});
} else {
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action,
conversationId: trackedConversationId.value,
stage: 'initial',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
}
}
}
/**
* Sends a follow-up message to refine the current generated content.
* @param {string} message - The follow-up message from the user
*/
async function sendFollowUp(message) {
if (!followUpContext.value || !message.trim()) return;
const requestController = new AbortController();
abortController.value = requestController;
isGenerating.value = true;
isContentReady.value = false;
// Track follow-up sent event
useTrack(CAPTAIN_EVENTS.FOLLOW_UP_SENT, {
conversationId: trackedConversationId.value,
});
followUpCount.value += 1;
try {
const {
message: content,
followUpContext: updatedContext,
errorType,
} = await followUp({
followUpContext: followUpContext.value,
message,
signal: requestController.signal,
});
if (requestController.signal.aborted) return;
if (errorType === CAPTAIN_ERROR_TYPES.ABORTED) {
if (abortController.value === requestController) {
isGenerating.value = false;
}
return;
}
if (content) {
generatedContent.value = content;
followUpContext.value = updatedContext;
showEditor.value = true;
} else if (errorType && errorType !== CAPTAIN_ERROR_TYPES.ABORTED) {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: errorType,
});
} else {
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: CAPTAIN_GENERATION_FAILURE_REASONS.EMPTY_RESPONSE,
});
}
isGenerating.value = false;
} catch (error) {
if (
requestController.signal.aborted ||
error?.name === CAPTAIN_ERROR_TYPES.ABORT_ERROR ||
error?.name === CAPTAIN_ERROR_TYPES.CANCELED_ERROR
) {
return;
}
trackGenerationFailure({
action: currentAction.value,
conversationId: trackedConversationId.value,
followUpCount: followUpCount.value,
stage: 'follow_up',
reason: error?.name || CAPTAIN_GENERATION_FAILURE_REASONS.EXCEPTION,
});
isGenerating.value = false;
} finally {
if (abortController.value === requestController) {
abortController.value = null;
}
}
}
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
* createState function based on the channel's schema.
* @returns {string} The content ready for the editor
*/
function accept() {
const content = generatedContent.value;
// Track "Applied" event
if (currentAction.value) {
const eventKey = `${getEventPrefix(currentAction.value)}_APPLIED`;
useTrack(
CAPTAIN_EVENTS[eventKey],
buildPayload(
currentAction.value,
trackedConversationId.value,
followUpCount.value
)
);
}
// Reset state without tracking dismiss
showEditor.value = false;
generatedContent.value = '';
followUpContext.value = null;
currentAction.value = null;
followUpCount.value = 0;
trackedConversationId.value = null;
return content;
}
return {
showEditor,
isGenerating,
isContentReady,
generatedContent,
followUpContext,
isActive,
isButtonDisabled,
editorTransitionKey,
reset,
toggleEditor,
setContentReady,
execute,
sendFollowUp,
accept,
};
}

View File

@@ -0,0 +1,73 @@
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
/**
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detectLegacy() {
const input = document.createElement('input');
input.style.position = 'fixed';
input.style.top = '-100px';
document.body.appendChild(input);
input.focus();
return new Promise(resolve => {
const keyboardEvent = new KeyboardEvent('keypress', {
key: 'y',
keyCode: 89,
which: 89,
bubbles: true,
cancelable: true,
});
const handler = e => {
document.body.removeChild(input);
document.removeEventListener('keypress', handler);
if (e.key === 'z') {
resolve(LAYOUT_QWERTY);
} else if (e.key === 'y') {
resolve(LAYOUT_QWERTZ);
} else {
resolve(LAYOUT_AZERTY);
}
};
document.addEventListener('keypress', handler);
input.dispatchEvent(keyboardEvent);
});
}
/**
* Detects the keyboard layout using the modern navigator.keyboard API.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
async function detect() {
const map = await navigator.keyboard.getLayoutMap();
const q = map.get('KeyQ');
const w = map.get('KeyW');
const e = map.get('KeyE');
const r = map.get('KeyR');
const t = map.get('KeyT');
const y = map.get('KeyY');
return [q, w, e, r, t, y].join('').toUpperCase();
}
/**
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
*/
export async function useDetectKeyboardLayout() {
const cachedLayout = window.cw_keyboard_layout;
if (cachedLayout) {
return cachedLayout;
}
const layout = navigator.keyboard ? await detect() : await detectLegacy();
window.cw_keyboard_layout = layout;
return layout;
}

View File

@@ -0,0 +1,129 @@
import useAutomationValues from './useAutomationValues';
import {
getCustomAttributeInputType,
filterCustomAttributes,
getStandardAttributeInputType,
isCustomAttribute,
} from 'dashboard/helper/automationHelper';
export function useEditableAutomation() {
const { getConditionDropdownValues, getActionDropdownValues } =
useAutomationValues();
/**
* This function sets the conditions for automation.
* It help to format the conditions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing conditions to manifest.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @returns {Array} An array of manifested conditions.
*/
const manifestConditions = (
automation,
allCustomAttributes,
automationTypes
) => {
const customAttributes = filterCustomAttributes(allCustomAttributes);
return automation.conditions.map(condition => {
const customAttr = isCustomAttribute(
customAttributes,
condition.attribute_key
);
let inputType = 'plain_text';
if (customAttr) {
inputType = getCustomAttributeInputType(customAttr.type);
} else {
inputType = getStandardAttributeInputType(
automationTypes,
automation.event_name,
condition.attribute_key
);
}
if (inputType === 'plain_text' || inputType === 'date') {
return { ...condition, values: condition.values[0] };
}
if (inputType === 'comma_separated_plain_text') {
return { ...condition, values: condition.values.join(',') };
}
return {
...condition,
query_operator: condition.query_operator || 'and',
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
item => [...condition.values].includes(item.id)
),
};
});
};
/**
* Generates an array of actions for the automation.
* @param {Object} action - The action object.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array|Object} Generated actions array or object based on input type.
*/
const generateActionsArray = (action, automationActionTypes) => {
const params = action.action_params;
const inputType = automationActionTypes.find(
item => item.key === action.action_name
).inputType;
if (inputType === 'multi_select' || inputType === 'search_select') {
return [...getActionDropdownValues(action.action_name)].filter(item =>
[...params].includes(item.id)
);
}
if (inputType === 'team_message') {
return {
team_ids: [...getActionDropdownValues(action.action_name)].filter(
item => [...params[0].team_ids].includes(item.id)
),
message: params[0].message,
};
}
return [...params];
};
/**
* This function sets the actions for automation.
* It help to format the actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object containing actions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Array} An array of manifested actions.
*/
const manifestActions = (automation, automationActionTypes) => {
return automation.actions.map(action => ({
...action,
action_params: action.action_params.length
? generateActionsArray(action, automationActionTypes)
: [],
}));
};
/**
* Formats the automation object for use when we edit the automation.
* It help to format the conditions and actions for the automation when we open the edit automation modal.
* @param {Object} automation - The automation object to format.
* @param {Array} allCustomAttributes - List of all custom attributes.
* @param {Object} automationTypes - Object containing automation type definitions.
* @param {Array} automationActionTypes - List of available automation action types.
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
*/
const formatAutomation = (
automation,
allCustomAttributes,
automationTypes,
automationActionTypes
) => {
return {
...automation,
conditions: manifestConditions(
automation,
allCustomAttributes,
automationTypes
),
actions: manifestActions(automation, automationActionTypes),
};
};
return { formatAutomation };
}

View File

@@ -0,0 +1,121 @@
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
import {
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE,
resolveMaximumFileUploadSize,
} from 'shared/helpers/FileHelper';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
/**
* Composable for handling file uploads in conversations
* @param {Object} options
* @param {Object} options.inbox - Current inbox object (has channel_type, medium, etc.)
* @param {Function} options.attachFile - Callback to handle file attachment
* @param {boolean} options.isPrivateNote - Whether the upload is for a private note
*/
export const useFileUpload = ({ inbox, attachFile, isPrivateNote = false }) => {
const { t } = useI18n();
const accountId = useMapGetter('getCurrentAccountId');
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const globalConfig = useMapGetter('globalConfig/get');
const installationLimit = resolveMaximumFileUploadSize(
globalConfig.value?.maximumFileUploadSize
);
// helper: compute max upload size for a given file's mime
const maxSizeFor = mime => {
// Use default/installation limit for private notes
if (isPrivateNote) {
return installationLimit;
}
const channelType = inbox?.channel_type;
if (!channelType || channelType === INBOX_TYPES.WEB) {
return installationLimit;
}
const channelLimit = getMaxUploadSizeByChannel({
channelType,
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
mime, // e.g. 'image/png'
});
if (channelLimit === DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE) {
return installationLimit;
}
return Math.min(channelLimit, installationLimit);
};
const alertOverLimit = maxSizeMB =>
useAlert(
t('CONVERSATION.FILE_SIZE_LIMIT', {
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
})
);
const handleDirectFileUpload = file => {
if (!file) return;
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
const upload = new DirectUpload(
file.file,
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
{
directUploadWillCreateBlobWithXHR: xhr => {
xhr.setRequestHeader(
'api_access_token',
currentUser.value.access_token
);
},
}
);
upload.create((error, blob) => {
if (error) {
useAlert(error);
} else {
attachFile({ file, blob });
}
});
};
const handleIndirectFileUpload = file => {
if (!file) return;
const mime = file.file?.type || file.type;
const maxSizeMB = maxSizeFor(mime);
if (!checkFileSizeLimit(file, maxSizeMB)) {
alertOverLimit(maxSizeMB);
return;
}
attachFile({ file });
};
const onFileUpload = file => {
if (globalConfig.value.directUploadsEnabled) {
handleDirectFileUpload(file);
} else {
handleIndirectFileUpload(file);
}
};
return { onFileUpload };
};

View File

@@ -0,0 +1,139 @@
/**
* @file useFontSize.js
* @description A composable for managing font size settings throughout the application.
* This handles font size selection, application to the DOM, and persistence in user settings.
*/
import { computed, watch } from 'vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
/**
* Font size options with their pixel values
* @type {Object}
*/
const FONT_SIZE_OPTIONS = {
SMALLER: '14px',
SMALL: '15px',
DEFAULT: '16px',
LARGE: '18px',
LARGER: '20px',
};
/**
* Array of font size option keys
* @type {Array<string>}
*/
const FONT_SIZE_NAMES = Object.keys(FONT_SIZE_OPTIONS);
/**
* Get font size label translation key
*
* @param {string} name - Font size name
* @returns {string} Translation key
*/
const getFontSizeLabelKey = name =>
`PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.${name}`;
/**
* Create font size option object
*
* @param {Function} t - Translation function
* @param {string} name - Font size name
* @returns {Object} Font size option with value and label
*/
const createFontSizeOption = (t, name) => ({
value: FONT_SIZE_OPTIONS[name],
label: t(getFontSizeLabelKey(name)),
});
/**
* Apply font size value to document root
*
* @param {string} pixelValue - Font size value in pixels
*/
const applyFontSizeToDOM = pixelValue => {
document.documentElement.style.setProperty(
'font-size',
pixelValue ?? FONT_SIZE_OPTIONS.DEFAULT
);
};
/**
* Font size management composable
*
* @returns {Object} Font size utilities and state
* @property {Array} fontSizeOptions - Array of font size options for select components
* @property {import('vue').ComputedRef<string>} currentFontSize - Current font size from UI settings
* @property {Function} applyFontSize - Function to apply font size to document
* @property {Function} updateFontSize - Function to update font size in settings with alert feedback
*/
export const useFontSize = () => {
const { uiSettings, updateUISettings } = useUISettings();
const { t } = useI18n();
/**
* Font size options for select dropdown
* @type {Array<{value: string, label: string}>}
*/
const fontSizeOptions = computed(() =>
FONT_SIZE_NAMES.map(name => createFontSizeOption(t, name))
);
/**
* Current font size from UI settings
* @type {import('vue').ComputedRef<string>}
*/
const currentFontSize = computed(
() => uiSettings.value.font_size || FONT_SIZE_OPTIONS.DEFAULT
);
/**
* Apply font size to document root
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
* @returns {void}
*/
const applyFontSize = pixelValue => {
// Use requestAnimationFrame for better performance
requestAnimationFrame(() => applyFontSizeToDOM(pixelValue));
};
/**
* Update font size in settings and apply to document
* Shows success/error alerts
* @param {string} pixelValue - Font size in pixels (e.g., '16px')
* @returns {Promise<void>}
*/
const updateFontSize = async pixelValue => {
try {
await updateUISettings({ font_size: pixelValue });
applyFontSize(pixelValue);
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS')
);
} catch (error) {
useAlert(
t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR')
);
}
};
// Watch for changes to the font size in UI settings
watch(
() => uiSettings.value.font_size,
newSize => {
applyFontSize(newSize);
},
{ immediate: true }
);
return {
fontSizeOptions,
currentFontSize,
applyFontSize,
updateFontSize,
};
};
export default useFontSize;

View File

@@ -0,0 +1,186 @@
import { ref, computed } from 'vue';
import {
debounce,
calculateCenterOffset,
applyRotationTransform,
normalizeToPercentage,
} from '@chatwoot/utils';
// Composable for images in gallery view
export const useImageZoom = imageRef => {
const MAX_ZOOM_LEVEL = 3;
const MIN_ZOOM_LEVEL = 1;
const ZOOM_INCREMENT = 0.2;
const MOUSE_MOVE_DEBOUNCE_MS = 100;
const MOUSE_LEAVE_DEBOUNCE_MS = 110;
const DEFAULT_IMG_TRANSFORM_ORIGIN = 'center center';
const zoomScale = ref(1);
const imgTransformOriginPoint = ref(DEFAULT_IMG_TRANSFORM_ORIGIN);
const activeImageRotation = ref(0);
const imageWrapperStyle = computed(() => ({
transform: `rotate(${activeImageRotation.value}deg)`,
}));
const imageStyle = computed(() => ({
transform: `scale(${zoomScale.value})`,
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
transformOrigin: `${imgTransformOriginPoint.value}`,
}));
// Resets the transform origin to center
const resetTransformOrigin = () => {
if (imageRef.value) {
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
}
};
// Rotates the current image
const onRotate = type => {
if (!imageRef.value) return;
resetTransformOrigin();
const rotation = type === 'clockwise' ? 90 : -90;
// ensure that the value of the rotation is within the range of -360 to 360 degrees
activeImageRotation.value = (activeImageRotation.value + rotation) % 360;
// Reset zoom when rotating
zoomScale.value = 1;
resetTransformOrigin();
};
/**
* Calculates the appropriate transform origin point based on mouse position and image rotation
* Used to create a natural zoom behavior where the image zooms toward/from the cursor position
*
* @param {number} x - The client X coordinate of the mouse pointer
* @param {number} y - The client Y coordinate of the mouse pointer
* @returns {{x: number, y: number}} Object containing the transform origin coordinates as percentages
*/
const getZoomOrigin = (x, y) => {
// Default to center
if (!imageRef.value) return { x: 50, y: 50 };
const rect = imageRef.value.getBoundingClientRect();
// Step 1: Calculate offset from center
const { relativeX, relativeY } = calculateCenterOffset(x, y, rect);
// Step 2: Apply rotation transformation
const { rotatedX, rotatedY } = applyRotationTransform(
relativeX,
relativeY,
activeImageRotation.value
);
// Step 3: Convert to percentage coordinates
return normalizeToPercentage(rotatedX, rotatedY, rect.width, rect.height);
};
// Handles zooming the image
const onZoom = (scale, x, y) => {
if (!imageRef.value) return;
// Calculate new scale within bounds
const newScale = Math.max(
MIN_ZOOM_LEVEL,
Math.min(MAX_ZOOM_LEVEL, zoomScale.value + scale)
);
// Skip if no change
if (newScale === zoomScale.value) return;
// Update transform origin based on mouse position and zoom scale is minimum
if (x != null && y != null && zoomScale.value === MIN_ZOOM_LEVEL) {
const { x: originX, y: originY } = getZoomOrigin(x, y);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
}
// Apply the new scale
zoomScale.value = newScale;
};
// Handles double-click zoom toggling
const onDoubleClickZoomImage = e => {
if (!imageRef.value) return;
e.preventDefault();
// Toggle between max zoom and min zoom
const newScale =
zoomScale.value >= MAX_ZOOM_LEVEL ? MIN_ZOOM_LEVEL : MAX_ZOOM_LEVEL;
// Update transform origin based on mouse position
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
// Apply the new scale
zoomScale.value = newScale;
};
// Handles mouse wheel zooming for images
const onWheelImageZoom = e => {
if (!imageRef.value) return;
e.preventDefault();
const scale = e.deltaY > 0 ? -ZOOM_INCREMENT : ZOOM_INCREMENT;
onZoom(scale, e.clientX, e.clientY);
};
/**
* Sets transform origin to mouse position during hover.
* Enables precise scroll/double-click zoom targeting by updating the
* transform origin to cursor position. Only active at minimum zoom level.
* Debounced (100ms) to improve performance during rapid mouse movement.
*/
const onMouseMove = debounce(
e => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
const { x: originX, y: originY } = getZoomOrigin(e.clientX, e.clientY);
imgTransformOriginPoint.value = `${originX}% ${originY}%`;
},
MOUSE_MOVE_DEBOUNCE_MS,
false
);
/**
* Resets transform origin to center when mouse leaves image.
* Ensures button-based zooming works predictably after hover ends.
* Uses slightly longer debounce (110ms) to avoid conflicts with onMouseMove.
*/
const onMouseLeave = debounce(
() => {
if (!imageRef.value) return;
if (zoomScale.value !== MIN_ZOOM_LEVEL) return;
imgTransformOriginPoint.value = DEFAULT_IMG_TRANSFORM_ORIGIN;
},
MOUSE_LEAVE_DEBOUNCE_MS,
false
);
const resetZoomAndRotation = () => {
activeImageRotation.value = 0;
zoomScale.value = 1;
resetTransformOrigin();
};
return {
zoomScale,
imgTransformOriginPoint,
activeImageRotation,
imageWrapperStyle,
imageStyle,
getZoomOrigin,
resetTransformOrigin,
onRotate,
onZoom,
onDoubleClickZoomImage,
onWheelImageZoom,
onMouseMove,
onMouseLeave,
resetZoomAndRotation,
};
};

View File

@@ -0,0 +1,10 @@
import { computed } from 'vue';
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
export function useImpersonation() {
const isImpersonating = computed(() => {
return SessionStorage.get(SESSION_STORAGE_KEYS.IMPERSONATION_USER);
});
return { isImpersonating };
}

View File

@@ -0,0 +1,161 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',
REPLY_TO_OUTGOING: 'replyToOutgoing',
};
// This is a single source of truth for inbox features
// This is used to check if a feature is available for a particular inbox or not
export const INBOX_FEATURE_MAP = {
[INBOX_FEATURES.REPLY_TO]: [
INBOX_TYPES.FB,
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
INBOX_TYPES.WEB,
INBOX_TYPES.TWITTER,
INBOX_TYPES.WHATSAPP,
INBOX_TYPES.TELEGRAM,
INBOX_TYPES.TIKTOK,
INBOX_TYPES.API,
],
};
/**
* Composable for handling inbox-related functionality
* @param {string|null} inboxId - Optional inbox ID. If not provided, uses current chat's inbox
* @returns {Object} An object containing inbox type checking functions
*/
export const useInbox = (inboxId = null) => {
const currentChat = useMapGetter('getSelectedChat');
const inboxGetter = useMapGetter('inboxes/getInboxById');
const inbox = computed(() => {
const targetInboxId = inboxId || currentChat.value?.inbox_id;
if (!targetInboxId) return null;
return useCamelCase(inboxGetter.value(targetInboxId), { deep: true });
});
const channelType = computed(() => {
return inbox.value?.channelType;
});
const isAPIInbox = computed(() => {
return channelType.value === INBOX_TYPES.API;
});
const isAFacebookInbox = computed(() => {
return channelType.value === INBOX_TYPES.FB;
});
const isAWebWidgetInbox = computed(() => {
return channelType.value === INBOX_TYPES.WEB;
});
const isATwilioChannel = computed(() => {
return channelType.value === INBOX_TYPES.TWILIO;
});
const isALineChannel = computed(() => {
return channelType.value === INBOX_TYPES.LINE;
});
const isAnEmailChannel = computed(() => {
return channelType.value === INBOX_TYPES.EMAIL;
});
const isATelegramChannel = computed(() => {
return channelType.value === INBOX_TYPES.TELEGRAM;
});
const whatsAppAPIProvider = computed(() => {
return inbox.value?.provider || '';
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value?.provider === 'microsoft';
});
const isAGoogleInbox = computed(() => {
return isAnEmailChannel.value && inbox.value?.provider === 'google';
});
const isATwilioSMSChannel = computed(() => {
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'sms';
});
const isASmsInbox = computed(() => {
return channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value;
});
const isATwilioWhatsAppChannel = computed(() => {
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'whatsapp';
});
const isAWhatsAppCloudChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'whatsapp_cloud'
);
});
const is360DialogWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP &&
whatsAppAPIProvider.value === 'default'
);
});
const isAWhatsAppChannel = computed(() => {
return (
channelType.value === INBOX_TYPES.WHATSAPP ||
isATwilioWhatsAppChannel.value
);
});
const isAnInstagramChannel = computed(() => {
return channelType.value === INBOX_TYPES.INSTAGRAM;
});
const isATiktokChannel = computed(() => {
return channelType.value === INBOX_TYPES.TIKTOK;
});
const isAVoiceChannel = computed(() => {
return channelType.value === INBOX_TYPES.VOICE;
});
return {
inbox,
isAFacebookInbox,
isALineChannel,
isAPIInbox,
isASmsInbox,
isATelegramChannel,
isATwilioChannel,
isAWebWidgetInbox,
isAWhatsAppChannel,
isAMicrosoftInbox,
isAGoogleInbox,
isATwilioWhatsAppChannel,
isAWhatsAppCloudChannel,
is360DialogWhatsAppChannel,
isAnEmailChannel,
isAnInstagramChannel,
isATiktokChannel,
isAVoiceChannel,
};
};

View File

@@ -0,0 +1,68 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
/**
* Composable for managing integration hooks
* @param {string|number} integrationId - The ID of the integration
* @returns {Object} An object containing computed properties for the integration
*/
export const useIntegrationHook = integrationId => {
const integrationGetter = useMapGetter('integrations/getIntegration');
/**
* The integration object
* @type {import('vue').ComputedRef<Object>}
*/
const integration = computed(() => {
return integrationGetter.value(integrationId);
});
/**
* Whether the integration hook type is 'inbox'
* @type {import('vue').ComputedRef<boolean>}
*/
const isHookTypeInbox = computed(() => {
return integration.value.hook_type === 'inbox';
});
/**
* Whether the integration has any connected hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const hasConnectedHooks = computed(() => {
return !!integration.value.hooks.length;
});
/**
* The type of integration: 'multiple' or 'single'
* @type {import('vue').ComputedRef<string>}
*/
const integrationType = computed(() => {
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
});
/**
* Whether the integration allows multiple hooks
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationMultiple = computed(() => {
return integrationType.value === 'multiple';
});
/**
* Whether the integration allows only a single hook
* @type {import('vue').ComputedRef<boolean>}
*/
const isIntegrationSingle = computed(() => {
return integrationType.value === 'single';
});
return {
integration,
integrationType,
isIntegrationMultiple,
isIntegrationSingle,
isHookTypeInbox,
hasConnectedHooks,
};
};

View File

@@ -0,0 +1,90 @@
import {
isActiveElementTypeable,
isEscape,
keysToModifyInQWERTZ,
LAYOUT_QWERTZ,
} from 'shared/helpers/KeyboardHelpers';
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import { createKeybindingsHandler } from 'tinykeys';
import { onUnmounted, onMounted } from 'vue';
/**
* Determines if the keyboard event should be ignored based on the element type and handler settings.
* @param {Event} e - The event object.
* @param {Object|Function} handler - The handler configuration or function.
* @returns {boolean} - True if the event should be ignored, false otherwise.
*/
const shouldIgnoreEvent = (e, handler) => {
const isTypeable = isActiveElementTypeable(e);
const allowOnFocusedInput =
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
if (isTypeable) {
if (isEscape(e)) {
e.target.blur();
}
return !allowOnFocusedInput;
}
return false;
};
/**
* Wraps the event handler to include custom logic before executing the handler.
* @param {Function} handler - The original event handler.
* @returns {Function} - The wrapped handler.
*/
const keydownWrapper = handler => {
return e => {
if (shouldIgnoreEvent(e, handler)) return;
// extract the action to perform from the handler
const actionToPerform =
typeof handler === 'function' ? handler : handler.action;
actionToPerform(e);
};
};
/**
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
* @param {Object} events - The object containing event names and their handlers.
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
*/
async function wrapEventsInKeybindingsHandler(events) {
const wrappedEvents = {};
const currentLayout = await useDetectKeyboardLayout();
Object.keys(events).forEach(originalEventName => {
const modifiedEventName =
currentLayout === LAYOUT_QWERTZ &&
keysToModifyInQWERTZ.has(originalEventName)
? `Shift+${originalEventName}`
: originalEventName;
wrappedEvents[modifiedEventName] = keydownWrapper(
events[originalEventName]
);
});
return wrappedEvents;
}
/**
* Vue composable to handle keyboard events with support for different keyboard layouts.
* @param {Object} keyboardEvents - The keyboard events to handle.
*/
export async function useKeyboardEvents(keyboardEvents) {
let abortController = new AbortController();
onMounted(async () => {
if (!keyboardEvents) return;
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
const keydownHandler = createKeybindingsHandler(wrappedEvents);
document.addEventListener('keydown', keydownHandler, {
signal: abortController.signal,
});
});
onUnmounted(() => {
abortController.abort();
});
}

View File

@@ -0,0 +1,117 @@
/**
* This composable provides keyboard navigation functionality for list-like UI components
* such as dropdowns, autocomplete suggestions, or any list of selectable items.
*
* TODO - Things that can be improved in the future
* - The scrolling should be handled by the component instead of the consumer of this composable
* it can be done if we know the item height.
* - The focus should be trapped within the list.
* - onSelect should be callback instead of a function that is passed
*/
import { useKeyboardEvents } from './useKeyboardEvents';
/**
* Wrap the action in a function that calls the action and prevents the default event behavior.
* @param {Function} action - The action to be called.
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
*/
const createAction = action => ({
action: e => {
action();
e.preventDefault();
},
allowOnFocusedInput: true,
});
/**
* Creates keyboard event handlers for navigation.
* @param {Function} moveSelectionUp - Function to move selection up.
* @param {Function} moveSelectionDown - Function to move selection down.
* @param {Function} [onSelect] - Optional function to handle selection.
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
* @returns {Object.<string, {action: Function, allowOnFocusedInput: boolean}>}
*/
const createKeyboardEvents = (
moveSelectionUp,
moveSelectionDown,
onSelect,
items
) => {
const events = {
ArrowUp: createAction(moveSelectionUp),
'Control+KeyP': createAction(moveSelectionUp),
ArrowDown: createAction(moveSelectionDown),
'Control+KeyN': createAction(moveSelectionDown),
};
// Adds an event handler for the Enter key if the onSelect function is provided.
if (typeof onSelect === 'function') {
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
}
return events;
};
/**
* Updates the selection index based on the current index, total number of items, and direction of movement.
*
* @param {number} currentIndex - The current index of the selected item.
* @param {number} itemsLength - The total number of items in the list.
* @param {string} direction - The direction of movement, either 'up' or 'down'.
* @returns {number} The new index after moving in the specified direction.
*/
const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
// If the selected index is the first item, move to the last item
// If the selected index is the last item, move to the first item
if (direction === 'up') {
return currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
}
return currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
};
/**
* A composable for handling keyboard navigation in mention selection scenarios.
*
* @param {Object} options - The options for the composable.
* @param {import('vue').Ref<HTMLElement>} options.elementRef - A ref to the DOM element that will receive keyboard events.
* @param {import('vue').Ref<Array>} options.items - A ref to the array of selectable items.
* @param {Function} [options.onSelect] - An optional function to be called when an item is selected.
* @param {Function} options.adjustScroll - A function to adjust the scroll position after selection changes.
* @param {import('vue').Ref<number>} options.selectedIndex - A ref to the currently selected index.
* @returns {{
* moveSelectionUp: Function,
* moveSelectionDown: Function
* }} An object containing functions to move the selection up and down.
*/
export function useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
}) {
const moveSelection = direction => {
selectedIndex.value = updateSelectionIndex(
selectedIndex.value,
items.value.length,
direction
);
adjustScroll();
};
const moveSelectionUp = () => moveSelection('up');
const moveSelectionDown = () => moveSelection('down');
const keyboardEvents = createKeyboardEvents(
moveSelectionUp,
moveSelectionDown,
onSelect,
items
);
useKeyboardEvents(keyboardEvents);
return {
moveSelectionUp,
moveSelectionDown,
};
}

View File

@@ -0,0 +1,80 @@
import { computed, onMounted } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import TasksAPI from 'dashboard/api/captain/tasks';
/**
* Cleans and normalizes a list of labels.
* @param {string} labels - A comma-separated string of labels.
* @returns {string[]} An array of cleaned and unique labels.
*/
const cleanLabels = labels => {
return labels
.toLowerCase()
.split(',')
.filter(label => label.trim())
.map(label => label.trim())
.filter((label, index, self) => self.indexOf(label) === index);
};
export function useLabelSuggestions() {
const store = useStore();
const { isCloudFeatureEnabled } = useAccount();
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
const currentChat = useMapGetter('getSelectedChat');
const conversationId = computed(() => currentChat.value?.id);
const captainTasksEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_TASKS);
});
const aiIntegration = computed(
() =>
appIntegrations.value.find(
integration => integration.id === 'openai' && !!integration.hooks.length
)?.hooks[0]
);
const isLabelSuggestionFeatureEnabled = computed(() => {
if (aiIntegration.value) {
const { settings = {} } = aiIntegration.value || {};
return !!settings.label_suggestion;
}
return false;
});
const fetchIntegrationsIfRequired = async () => {
if (!appIntegrations.value.length) {
await store.dispatch('integrations/get');
}
};
/**
* Gets label suggestions for the current conversation.
* @returns {Promise<string[]>} An array of suggested labels.
*/
const getLabelSuggestions = async () => {
if (!conversationId.value) return [];
try {
const result = await TasksAPI.labelSuggestion(conversationId.value);
const {
data: { message: labels },
} = result;
return cleanLabels(labels);
} catch {
return [];
}
};
onMounted(() => {
fetchIntegrationsIfRequired();
});
return {
captainTasksEnabled,
isLabelSuggestionFeatureEnabled,
getLabelSuggestions,
};
}

View File

@@ -0,0 +1,28 @@
import { ref, onBeforeUnmount } from 'vue';
export const useLiveRefresh = (callback, interval = 60000) => {
const timeoutId = ref(null);
const startRefetching = () => {
timeoutId.value = setTimeout(async () => {
await callback();
startRefetching();
}, interval);
};
const stopRefetching = () => {
if (timeoutId.value) {
clearTimeout(timeoutId.value);
timeoutId.value = null;
}
};
onBeforeUnmount(() => {
stopRefetching();
});
return {
startRefetching,
stopRefetching,
};
};

View File

@@ -0,0 +1,49 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
*/
export const useMacros = () => {
const { t } = useI18n();
const getters = useStoreGetters();
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
/**
* Get dropdown values based on the specified type
* @param {string} type - The type of dropdown values to retrieve
* @returns {Array} An array of dropdown values
*/
const getMacroDropdownValues = type => {
switch (type) {
case 'assign_team':
case 'send_email_to_team':
return teams.value;
case 'assign_agent':
return [{ id: 'self', name: 'Self' }, ...agents.value];
case 'add_label':
case 'remove_label':
return labels.value.map(i => ({
id: i.title,
name: i.title,
}));
case 'change_priority':
return PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: t(`MACROS.PRIORITY_TYPES.${item.i18nKey}`),
}));
default:
return [];
}
};
return {
getMacroDropdownValues,
};
};

View File

@@ -0,0 +1,135 @@
import { computed, unref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import {
getUserPermissions,
hasPermissions,
} from 'dashboard/helper/permissionsHelper';
import { PREMIUM_FEATURES } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
export function usePolicy() {
const user = useMapGetter('getCurrentUser');
const isFeatureEnabled = useMapGetter('accounts/isFeatureEnabledonAccount');
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const isACustomBrandedInstance = useMapGetter(
'globalConfig/isACustomBrandedInstance'
);
const { isEnterprise, enterprisePlanName } = useConfig();
const { accountId } = useAccount();
const getUserPermissionsForAccount = () => {
return getUserPermissions(user.value, accountId.value);
};
const isFeatureFlagEnabled = featureFlag => {
if (!featureFlag) return true;
return isFeatureEnabled.value(accountId.value, featureFlag);
};
const checkPermissions = requiredPermissions => {
if (!requiredPermissions || !requiredPermissions.length) return true;
const userPermissions = getUserPermissionsForAccount();
return hasPermissions(requiredPermissions, userPermissions);
};
const checkInstallationType = config => {
if (Array.isArray(config) && config.length > 0) {
const installationCheck = {
[INSTALLATION_TYPES.ENTERPRISE]: isEnterprise,
[INSTALLATION_TYPES.CLOUD]: isOnChatwootCloud.value,
[INSTALLATION_TYPES.COMMUNITY]: true,
};
return config.some(type => installationCheck[type]);
}
return true;
};
const isPremiumFeature = featureFlag => {
if (!featureFlag) return true;
return PREMIUM_FEATURES.includes(featureFlag);
};
const hasPremiumEnterprise = computed(() => {
if (isEnterprise) return enterprisePlanName !== 'community';
return true;
});
const shouldShow = (featureFlag, permissions, installationTypes) => {
const flag = unref(featureFlag);
const perms = unref(permissions);
const installation = unref(installationTypes);
// if the user does not have permissions or installation type is not supported
// return false;
// This supersedes everything
if (!checkPermissions(perms)) return false;
if (!checkInstallationType(installation)) return false;
if (isACustomBrandedInstance.value) {
// if this is a custom branded instance, we just use the feature flag as a reference
return isFeatureFlagEnabled(flag);
}
// if on cloud, we should if the feature is allowed
// or if the feature is a premium one like SLA to show a paywall
// the paywall should be managed by the individual component
if (isOnChatwootCloud.value) {
return isFeatureFlagEnabled(flag) || isPremiumFeature(flag);
}
if (isEnterprise) {
// in enterprise, if the feature is premium but they don't have an enterprise plan
// we should it anyway this is to show upsells on enterprise regardless of the feature flag
// Feature flag is only honored if they have a premium plan
//
// In case they have a premium plan, the check on feature flag alone is enough
// because the second condition will always be false
// That means once subscribed, the feature can be disabled by the admin
//
// the paywall should be managed by the individual component
return (
isFeatureFlagEnabled(flag) ||
(isPremiumFeature(flag) && !hasPremiumEnterprise.value)
);
}
// default to true
return true;
};
const shouldShowPaywall = featureFlag => {
const flag = unref(featureFlag);
if (!flag) return false;
if (isACustomBrandedInstance.value) {
// custom branded instances never show paywall
return false;
}
if (isPremiumFeature(flag)) {
if (isOnChatwootCloud.value) {
return !isFeatureFlagEnabled(flag);
}
if (isEnterprise) {
return !hasPremiumEnterprise.value;
}
}
return false;
};
return {
checkPermissions,
shouldShowPaywall,
isFeatureFlagEnabled,
shouldShow,
};
}

View File

@@ -0,0 +1,62 @@
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',
summarFetchingKey = 'getAccountSummaryFetchingStatus'
) {
const accountSummary = useMapGetter(accountSummaryKey);
const fetchingStatus = useMapGetter(summarFetchingKey);
/**
* 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,
fetchingStatus,
};
}

View File

@@ -0,0 +1,50 @@
// NOTE: In the future if performance becomes an issue, we can memoize the functions
import { unref } from 'vue';
import camelcaseKeys from 'camelcase-keys';
import snakecaseKeys from 'snakecase-keys';
import * as Sentry from '@sentry/vue';
/**
* Vue composable that converts object keys to camelCase
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
* @param {Object} [options] - Options object
* @param {boolean} [options.deep=false] - Should convert keys of nested objects
* @returns {Object|Array} Converted payload with camelCase keys
*/
export function useCamelCase(payload, options) {
try {
const unrefPayload = unref(payload);
return camelcaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'camelCase',
});
Sentry.captureException(e);
return payload;
}
}
/**
* Vue composable that converts object keys to snake_case
* @param {Object|Array|import('vue').Ref<Object|Array>} payload - Object or array to convert
* @param {Object} [options] - Options object
* @param {boolean} [options.deep=false] - Should convert keys of nested objects
* @returns {Object|Array} Converted payload with snake_case keys
*/
export function useSnakeCase(payload, options) {
try {
const unrefPayload = unref(payload);
return snakecaseKeys(unrefPayload, options);
} catch (e) {
Sentry.setContext('transform-keys-error', {
payload,
options,
op: 'snakeCase',
});
Sentry.captureException(e);
return payload;
}
}

View File

@@ -0,0 +1,49 @@
import { computed } from 'vue';
import { useUISettings } from './useUISettings';
import { useAccount } from './useAccount';
/**
* Select translation based on locale priority.
* @param {Object} translations - Translations object with locale keys
* @param {string} agentLocale - Agent's preferred locale
* @param {string} accountLocale - Account's default locale
* @returns {string|null} Selected translation or null
*/
export function selectTranslation(translations, agentLocale, accountLocale) {
if (!translations || Object.keys(translations).length === 0) return null;
if (agentLocale && translations[agentLocale]) {
return translations[agentLocale];
}
if (accountLocale && translations[accountLocale]) {
return translations[accountLocale];
}
return translations[Object.keys(translations)[0]];
}
/**
* Composable to extract translation state/content from contentAttributes.
* @param {Ref|Reactive} contentAttributes - Ref or reactive object containing `translations` property
* @returns {Object} { hasTranslations, translationContent }
*/
export function useTranslations(contentAttributes) {
const { uiSettings } = useUISettings();
const { currentAccount } = useAccount();
const hasTranslations = computed(() => {
if (!contentAttributes.value) return false;
const { translations = {} } = contentAttributes.value;
return Object.keys(translations || {}).length > 0;
});
const translationContent = computed(() => {
if (!hasTranslations.value) return null;
return selectTranslation(
contentAttributes.value.translations,
uiSettings.value?.locale,
currentAccount.value?.locale
);
});
return { hasTranslations, translationContent };
}

View File

@@ -0,0 +1,170 @@
import { computed } from 'vue';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
export const DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'conversation_actions' },
{ name: 'macros' },
{ name: 'conversation_info' },
{ name: 'contact_attributes' },
{ name: 'contact_notes' },
{ name: 'previous_conversation' },
{ name: 'conversation_participants' },
{ name: 'linear_issues' },
{ name: 'shopify_orders' },
]);
export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = Object.freeze([
{ name: 'contact_attributes' },
{ name: 'contact_labels' },
{ name: 'previous_conversation' },
]);
/**
* Slugifies the channel name.
* Replaces spaces, hyphens, and double colons with underscores.
* @param {string} name - The channel name to slugify.
* @returns {string} The slugified channel name.
*/
const slugifyChannel = name =>
name?.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
/**
* Computes the order of items in the conversation sidebar, using defaults if not present.
* @param {Object} uiSettings - Reactive UI settings object.
* @returns {Array} Ordered list of sidebar items.
*/
const useConversationSidebarItemsOrder = uiSettings => {
return computed(() => {
const { conversation_sidebar_items_order: itemsOrder } = uiSettings.value;
// If the sidebar order is not set, use the default order.
if (!itemsOrder) {
return [...DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER];
}
// Create a copy of itemsOrder to avoid mutating the original store object.
const itemsOrderCopy = [...itemsOrder];
// If the sidebar order doesn't have the new elements, then add them to the list.
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER.forEach(item => {
if (!itemsOrderCopy.find(i => i.name === item.name)) {
itemsOrderCopy.push(item);
}
});
return itemsOrderCopy;
});
};
/**
* Computes the order of items in the contact sidebar,using defaults if not present.
* @param {Object} uiSettings - Reactive UI settings object.
* @returns {Array} Ordered list of sidebar items.
*/
const useContactSidebarItemsOrder = uiSettings => {
return computed(() => {
const { contact_sidebar_items_order: itemsOrder } = uiSettings.value;
return itemsOrder || DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER;
});
};
/**
* Toggles the open state of a sidebar item.
* @param {string} key - The key of the sidebar item to toggle.
* @param {Object} uiSettings - Reactive UI settings object.
* @param {Function} updateUISettings - Function to update UI settings.
*/
const toggleSidebarUIState = (key, uiSettings, updateUISettings) => {
updateUISettings({ [key]: !uiSettings.value[key] });
};
/**
* Sets the signature flag for a specific channel type in the inbox settings.
* @param {string} channelType - The type of the channel.
* @param {boolean} value - The value to set for the signature enabled flag.
* @param {Function} updateUISettings - Function to update UI settings.
*/
const setSignatureFlagForInbox = (channelType, value, updateUISettings) => {
if (!channelType) return;
const slugifiedChannel = slugifyChannel(channelType);
updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value });
};
const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => {
if (!channelType) return;
const slugifiedChannel = slugifyChannel(channelType);
updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value });
};
/**
* Fetches the signature flag for a specific channel type from UI settings.
* @param {string} channelType - The type of the channel.
* @param {Object} uiSettings - Reactive UI settings object.
* @returns {boolean} The value of the signature enabled flag.
*/
const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => {
if (!channelType) return false;
const slugifiedChannel = slugifyChannel(channelType);
return uiSettings.value[`${slugifiedChannel}_signature_enabled`];
};
const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => {
if (!channelType) return false;
const slugifiedChannel = slugifyChannel(channelType);
return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`];
};
/**
* Checks if a specific editor hotkey is enabled.
* @param {string} key - The key to check.
* @param {Object} uiSettings - Reactive UI settings object.
* @returns {boolean} True if the hotkey is enabled, otherwise false.
*/
const isEditorHotKeyEnabled = (key, uiSettings) => {
const {
editor_message_key: editorMessageKey,
enter_to_send_enabled: enterToSendEnabled,
} = uiSettings.value || {};
if (!editorMessageKey) {
return key === (enterToSendEnabled ? 'enter' : 'cmd_enter');
}
return editorMessageKey === key;
};
/**
* Main composable function for managing UI settings.
* @returns {Object} An object containing reactive properties and methods for UI settings management.
*/
export function useUISettings() {
const getters = useStoreGetters();
const store = useStore();
const uiSettings = computed(() => getters.getUISettings.value);
const updateUISettings = (settings = {}) => {
store.dispatch('updateUISettings', {
uiSettings: {
...uiSettings.value,
...settings,
},
});
};
return {
uiSettings,
updateUISettings,
conversationSidebarItemsOrder: useConversationSidebarItemsOrder(uiSettings),
contactSidebarItemsOrder: useContactSidebarItemsOrder(uiSettings),
isContactSidebarItemOpen: key => !!uiSettings.value[key],
toggleSidebarUIState: key =>
toggleSidebarUIState(key, uiSettings, updateUISettings),
setSignatureFlagForInbox: (channelType, value) =>
setSignatureFlagForInbox(channelType, value, updateUISettings),
fetchSignatureFlagFromUISettings: channelType =>
fetchSignatureFlagFromUISettings(channelType, uiSettings),
setQuotedReplyFlagForInbox: (channelType, value) =>
setQuotedReplyFlagForInbox(channelType, value, updateUISettings),
fetchQuotedReplyFlagFromUISettings: channelType =>
fetchQuotedReplyFlagFromUISettings(channelType, uiSettings),
isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings),
};
}

View File

@@ -0,0 +1,37 @@
import { computed } from 'vue';
function isMacOS() {
// Check modern userAgentData API first
if (navigator.userAgentData?.platform) {
return navigator.userAgentData.platform === 'macOS';
}
// Fallback to navigator.platform
return (
navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
);
}
export function useKbd(keys) {
const keySymbols = {
$mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
enter: '↵',
tab: '⇥',
esc: '⎋',
};
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
.join(' ')
.toUpperCase();
});
}
export function getModifierKey() {
return isMacOS() ? '⌘' : 'Ctrl';
}