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,224 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useAttachments } from '../useAttachments';
import { useStore } from 'vuex';
import { computed } from 'vue';
// Mock Vue's useStore
vi.mock('vuex', () => ({
useStore: vi.fn(),
}));
// Mock Vue's computed
vi.mock('vue', () => ({
computed: vi.fn(fn => ({ value: fn() })),
}));
describe('useAttachments', () => {
let mockStore;
let mockGetters;
beforeEach(() => {
// Reset window.chatwootWebChannel
delete window.chatwootWebChannel;
// Create mock store
mockGetters = {};
mockStore = {
getters: mockGetters,
};
vi.mocked(useStore).mockReturnValue(mockStore);
// Mock computed to return a reactive-like object
vi.mocked(computed).mockImplementation(fn => ({ value: fn() }));
});
afterEach(() => {
vi.clearAllMocks();
});
describe('shouldShowFilePicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBe(true);
});
it('returns undefined when not set in store', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
const { shouldShowFilePicker } = useAttachments();
expect(shouldShowFilePicker.value).toBeUndefined();
});
});
describe('hasAttachmentsEnabled', () => {
it('returns true when attachments are enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(true);
});
it('returns false when attachments are not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config has no enabled features', () => {
window.chatwootWebChannel = {
enabledFeatures: [],
};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when channel config is missing', () => {
window.chatwootWebChannel = undefined;
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
it('returns false when enabledFeatures is missing', () => {
window.chatwootWebChannel = {};
const { hasAttachmentsEnabled } = useAttachments();
expect(hasAttachmentsEnabled.value).toBe(false);
});
});
describe('canHandleAttachments', () => {
beforeEach(() => {
// Set up a default channel config
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
});
it('prioritizes SDK flag when explicitly set to true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('prioritizes SDK flag when explicitly set to false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('falls back to inbox settings when SDK flag is undefined', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
it('falls back to inbox settings when SDK flag is undefined and attachments disabled', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK false over inbox settings true', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = false;
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(false);
});
it('prioritizes SDK true over inbox settings false', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['emoji'], // no attachments
};
const { canHandleAttachments } = useAttachments();
expect(canHandleAttachments.value).toBe(true);
});
});
describe('hasEmojiPickerEnabled', () => {
it('returns true when emoji picker is enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['emoji_picker', 'attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(true);
});
it('returns false when emoji picker is not enabled in channel config', () => {
window.chatwootWebChannel = {
enabledFeatures: ['attachments'],
};
const { hasEmojiPickerEnabled } = useAttachments();
expect(hasEmojiPickerEnabled.value).toBe(false);
});
});
describe('shouldShowEmojiPicker', () => {
it('returns value from store getter', () => {
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
const { shouldShowEmojiPicker } = useAttachments();
expect(shouldShowEmojiPicker.value).toBe(true);
});
});
describe('integration test', () => {
it('returns all expected properties', () => {
mockGetters['appConfig/getShouldShowFilePicker'] = undefined;
mockGetters['appConfig/getShouldShowEmojiPicker'] = true;
window.chatwootWebChannel = {
enabledFeatures: ['attachments', 'emoji_picker'],
};
const result = useAttachments();
expect(result).toHaveProperty('shouldShowFilePicker');
expect(result).toHaveProperty('shouldShowEmojiPicker');
expect(result).toHaveProperty('hasAttachmentsEnabled');
expect(result).toHaveProperty('hasEmojiPickerEnabled');
expect(result).toHaveProperty('canHandleAttachments');
expect(Object.keys(result)).toHaveLength(5);
});
});
});

View File

@@ -0,0 +1,125 @@
import { ref } from 'vue';
import { useAvailability } from '../useAvailability';
const mockIsOnline = vi.fn();
const mockIsInWorkingHours = vi.fn();
const mockUseCamelCase = vi.fn(obj => obj);
vi.mock('widget/helpers/availabilityHelpers', () => ({
isOnline: (...args) => mockIsOnline(...args),
isInWorkingHours: (...args) => mockIsInWorkingHours(...args),
}));
vi.mock('dashboard/composables/useTransformKeys', () => ({
useCamelCase: obj => mockUseCamelCase(obj),
}));
describe('useAvailability', () => {
const originalWindow = window.chatwootWebChannel;
beforeEach(() => {
vi.clearAllMocks();
// Reset mocks to return true by default
mockIsOnline.mockReturnValue(true);
mockIsInWorkingHours.mockReturnValue(true);
mockUseCamelCase.mockImplementation(obj => obj);
window.chatwootWebChannel = {
workingHours: [],
workingHoursEnabled: false,
timezone: 'UTC',
utcOffset: 'UTC',
replyTime: 'in_a_few_minutes',
};
});
afterEach(() => {
window.chatwootWebChannel = originalWindow;
});
describe('initial state', () => {
it('should initialize with default values', () => {
const { availableAgents, hasOnlineAgents, isInWorkingHours, isOnline } =
useAvailability();
expect(availableAgents.value).toEqual([]);
expect(hasOnlineAgents.value).toBe(false);
expect(isInWorkingHours.value).toBe(true);
expect(isOnline.value).toBe(true);
});
});
describe('with agents', () => {
it('should handle agents array', () => {
const agents = [{ id: 1 }, { id: 2 }];
const { availableAgents, hasOnlineAgents } = useAvailability(agents);
expect(availableAgents.value).toEqual(agents);
expect(hasOnlineAgents.value).toBe(true);
});
it('should handle reactive agents', () => {
const agents = ref([{ id: 1 }]);
const { hasOnlineAgents } = useAvailability(agents);
expect(hasOnlineAgents.value).toBe(true);
agents.value = [];
expect(hasOnlineAgents.value).toBe(false);
});
});
describe('working hours', () => {
const workingHours = [{ dayOfWeek: 1, openHour: 9, closeHour: 17 }];
beforeEach(() => {
window.chatwootWebChannel = {
workingHours,
workingHoursEnabled: true,
utcOffset: '+05:30',
};
});
it('should check working hours', () => {
mockIsInWorkingHours.mockReturnValueOnce(true);
const { isInWorkingHours } = useAvailability();
const result = isInWorkingHours.value;
expect(result).toBe(true);
expect(mockIsInWorkingHours).toHaveBeenCalledWith(
expect.any(Date),
'+05:30',
workingHours
);
});
it('should determine online status based on working hours and agents', () => {
mockIsOnline.mockReturnValueOnce(true);
const { isOnline } = useAvailability([{ id: 1 }]);
const result = isOnline.value;
expect(result).toBe(true);
expect(mockIsOnline).toHaveBeenCalledWith(
true,
expect.any(Date),
'+05:30',
workingHours,
true
);
});
});
describe('config changes', () => {
it('should react to window.chatwootWebChannel changes', () => {
const { inboxConfig } = useAvailability();
window.chatwootWebChannel = {
...window.chatwootWebChannel,
replyTime: 'in_a_day',
};
expect(inboxConfig.value.replyTime).toBe('in_a_day');
});
});
});

View File

@@ -0,0 +1,49 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDarkMode } from '../useDarkMode';
import { useMapGetter } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(),
}));
describe('useDarkMode', () => {
let mockDarkMode;
beforeEach(() => {
mockDarkMode = { value: 'light' };
vi.mocked(useMapGetter).mockReturnValue(mockDarkMode);
});
it('returns darkMode, prefersDarkMode', () => {
const result = useDarkMode();
expect(result).toHaveProperty('darkMode');
expect(result).toHaveProperty('prefersDarkMode');
});
describe('prefersDarkMode', () => {
it('returns false when darkMode is light', () => {
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
it('returns true when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns true when darkMode is auto and OS prefers dark mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns false when darkMode is auto and OS prefers light mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
});
});

View File

@@ -0,0 +1,42 @@
import { computed } from 'vue';
import { useStore } from 'vuex';
export function useAttachments() {
const store = useStore();
const shouldShowFilePicker = computed(
() => store.getters['appConfig/getShouldShowFilePicker']
);
const shouldShowEmojiPicker = computed(
() => store.getters['appConfig/getShouldShowEmojiPicker']
);
const hasAttachmentsEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('attachments') || false;
});
const hasEmojiPickerEnabled = computed(() => {
const channelConfig = window.chatwootWebChannel;
return channelConfig?.enabledFeatures?.includes('emoji_picker') || false;
});
const canHandleAttachments = computed(() => {
// If enableFileUpload was explicitly set via SDK, prioritize that
if (shouldShowFilePicker.value !== undefined) {
return shouldShowFilePicker.value;
}
// Otherwise, fall back to inbox settings only
return hasAttachmentsEnabled.value;
});
return {
shouldShowFilePicker,
shouldShowEmojiPicker,
hasAttachmentsEnabled,
hasEmojiPickerEnabled,
canHandleAttachments,
};
}

View File

@@ -0,0 +1,73 @@
import { computed, unref } from 'vue';
import {
isOnline as checkIsOnline,
isInWorkingHours as checkInWorkingHours,
} from 'widget/helpers/availabilityHelpers';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
const DEFAULT_TIMEZONE = 'UTC';
const DEFAULT_REPLY_TIME = 'in_a_few_minutes';
/**
* Composable for availability-related logic
* @param {Ref|Array} agents - Available agents (can be ref or raw array)
* @returns {Object} Availability utilities and computed properties
*/
export function useAvailability(agents = []) {
// Now receives toRef(props, 'agents') from caller, which maintains reactivity.
// Use unref() inside computed to unwrap the ref value properly.
// This ensures availableAgents updates when the parent's agents prop changes
// (e.g., after API response updates the Vuex store).
const availableAgents = computed(() => unref(agents));
const channelConfig = computed(() => window.chatwootWebChannel || {});
const inboxConfig = computed(() => ({
workingHours: channelConfig.value.workingHours?.map(useCamelCase) || [],
workingHoursEnabled: channelConfig.value.workingHoursEnabled || false,
timezone: channelConfig.value.timezone || DEFAULT_TIMEZONE,
utcOffset:
channelConfig.value.utcOffset ||
channelConfig.value.timezone ||
DEFAULT_TIMEZONE,
replyTime: channelConfig.value.replyTime || DEFAULT_REPLY_TIME,
}));
const currentTime = computed(() => new Date());
const hasOnlineAgents = computed(() => {
const agentList = availableAgents.value || [];
return Array.isArray(agentList) ? agentList.length > 0 : false;
});
const isInWorkingHours = computed(() =>
checkInWorkingHours(
currentTime.value,
inboxConfig.value.utcOffset,
inboxConfig.value.workingHours
)
);
// Check if online (considering both working hours and agents)
const isOnline = computed(() =>
checkIsOnline(
inboxConfig.value.workingHoursEnabled,
currentTime.value,
inboxConfig.value.utcOffset,
inboxConfig.value.workingHours,
hasOnlineAgents.value
)
);
return {
channelConfig,
inboxConfig,
currentTime,
availableAgents,
hasOnlineAgents,
isOnline,
isInWorkingHours,
};
}

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
const isDarkMode = mode => mode === 'dark';
const getSystemPreference = () =>
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
const calculatePrefersDarkMode = (mode, systemPreference) =>
isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode);
/**
* Composable for handling dark mode.
* @returns {Object} An object containing computed properties and methods for dark mode.
*/
export function useDarkMode() {
const darkMode = useMapGetter('appConfig/darkMode');
const systemPreference = computed(getSystemPreference);
const prefersDarkMode = computed(() =>
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
return {
darkMode,
prefersDarkMode,
};
}