Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { useBranding } from '../useBranding';
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
// Mock the store composable
|
||||
vi.mock('dashboard/composables/store.js', () => ({
|
||||
useMapGetter: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useBranding', () => {
|
||||
let mockGlobalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGlobalConfig = {
|
||||
value: {
|
||||
installationName: 'MyCompany',
|
||||
},
|
||||
};
|
||||
|
||||
useMapGetter.mockReturnValue(mockGlobalConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('replaceInstallationName', () => {
|
||||
it('should replace "Chatwoot" with installation name when both text and installation name are provided', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to MyCompany');
|
||||
});
|
||||
|
||||
it('should replace multiple occurrences of "Chatwoot"', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Chatwoot is great! Use Chatwoot today.'
|
||||
);
|
||||
|
||||
expect(result).toBe('MyCompany is great! Use MyCompany today.');
|
||||
});
|
||||
|
||||
it('should return original text when installation name is not provided', () => {
|
||||
mockGlobalConfig.value = {};
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to Chatwoot');
|
||||
});
|
||||
|
||||
it('should return original text when globalConfig is not available', () => {
|
||||
mockGlobalConfig.value = undefined;
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to Chatwoot');
|
||||
});
|
||||
|
||||
it('should return original text when text is empty or null', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
|
||||
expect(replaceInstallationName('')).toBe('');
|
||||
expect(replaceInstallationName(null)).toBe(null);
|
||||
expect(replaceInstallationName(undefined)).toBe(undefined);
|
||||
});
|
||||
|
||||
it('should handle text without "Chatwoot" gracefully', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to our platform');
|
||||
|
||||
expect(result).toBe('Welcome to our platform');
|
||||
});
|
||||
|
||||
it('should be case-sensitive for "Chatwoot"', () => {
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName(
|
||||
'Welcome to chatwoot and CHATWOOT'
|
||||
);
|
||||
|
||||
expect(result).toBe('Welcome to chatwoot and CHATWOOT');
|
||||
});
|
||||
|
||||
it('should handle special characters in installation name', () => {
|
||||
mockGlobalConfig.value = {
|
||||
installationName: 'My-Company & Co.',
|
||||
};
|
||||
|
||||
const { replaceInstallationName } = useBranding();
|
||||
const result = replaceInstallationName('Welcome to Chatwoot');
|
||||
|
||||
expect(result).toBe('Welcome to My-Company & Co.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { useExpandableContent } from '../useExpandableContent';
|
||||
|
||||
// Mock VueUse composables
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useToggle: vi.fn(initialValue => {
|
||||
let value = initialValue;
|
||||
const toggle = newValue => {
|
||||
value = newValue !== undefined ? newValue : !value;
|
||||
};
|
||||
return [{ value }, toggle];
|
||||
}),
|
||||
useResizeObserver: vi.fn((element, callback) => {
|
||||
// Store callback for manual triggering in tests
|
||||
if (element.value) {
|
||||
callback();
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useExpandableContent', () => {
|
||||
let originalGetComputedStyle;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock window.getComputedStyle
|
||||
originalGetComputedStyle = window.getComputedStyle;
|
||||
window.getComputedStyle = vi.fn(() => ({
|
||||
lineHeight: '20px',
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.getComputedStyle = originalGetComputedStyle;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns expected properties', () => {
|
||||
const result = useExpandableContent();
|
||||
|
||||
expect(result).toHaveProperty('contentElement');
|
||||
expect(result).toHaveProperty('isExpanded');
|
||||
expect(result).toHaveProperty('needsToggle');
|
||||
expect(result).toHaveProperty('toggleExpanded');
|
||||
expect(result).toHaveProperty('checkOverflow');
|
||||
});
|
||||
|
||||
it('initializes with default values', () => {
|
||||
const { isExpanded, needsToggle } = useExpandableContent();
|
||||
|
||||
expect(isExpanded.value).toBe(false);
|
||||
expect(needsToggle.value).toBe(false);
|
||||
});
|
||||
|
||||
it('checkOverflow sets needsToggle to true when content overflows', async () => {
|
||||
const { contentElement, needsToggle, checkOverflow } =
|
||||
useExpandableContent();
|
||||
|
||||
// Mock element with overflow
|
||||
contentElement.value = {
|
||||
scrollHeight: 100, // Much larger than 2 lines (40px)
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
await nextTick();
|
||||
|
||||
expect(needsToggle.value).toBe(true);
|
||||
});
|
||||
|
||||
it('checkOverflow sets needsToggle to false when content fits', async () => {
|
||||
const { contentElement, needsToggle, checkOverflow } =
|
||||
useExpandableContent();
|
||||
|
||||
// Mock element without overflow
|
||||
contentElement.value = {
|
||||
scrollHeight: 30, // Less than 2 lines (40px)
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
await nextTick();
|
||||
|
||||
expect(needsToggle.value).toBe(false);
|
||||
});
|
||||
|
||||
it('respects custom maxLines option', async () => {
|
||||
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
|
||||
{
|
||||
maxLines: 3,
|
||||
}
|
||||
);
|
||||
|
||||
// Mock element that fits in 3 lines but not 2
|
||||
contentElement.value = {
|
||||
scrollHeight: 50, // Fits in 3 lines (60px) but not 2 lines (40px)
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
await nextTick();
|
||||
|
||||
expect(needsToggle.value).toBe(false);
|
||||
});
|
||||
|
||||
it('uses defaultLineHeight when computed style is unavailable', async () => {
|
||||
window.getComputedStyle = vi.fn(() => ({
|
||||
lineHeight: 'normal', // Not a valid number
|
||||
}));
|
||||
|
||||
const { contentElement, needsToggle, checkOverflow } = useExpandableContent(
|
||||
{
|
||||
defaultLineHeight: 16,
|
||||
}
|
||||
);
|
||||
|
||||
// Mock element that overflows with 16px line height (32px max)
|
||||
contentElement.value = {
|
||||
scrollHeight: 40,
|
||||
};
|
||||
|
||||
checkOverflow();
|
||||
await nextTick();
|
||||
|
||||
expect(needsToggle.value).toBe(true);
|
||||
});
|
||||
|
||||
it('handles null contentElement gracefully', () => {
|
||||
const { checkOverflow } = useExpandableContent();
|
||||
|
||||
// Should not throw when contentElement is null
|
||||
expect(() => checkOverflow()).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useFilter } from '../useFilter';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('vue-i18n');
|
||||
|
||||
describe('useFilter', () => {
|
||||
// Setup mocks
|
||||
beforeEach(() => {
|
||||
vi.mocked(useStore).mockReturnValue({
|
||||
getters: {
|
||||
'attributes/getAttributesByModel': vi.fn(),
|
||||
},
|
||||
});
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
t: vi.fn(key => key),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct functions', () => {
|
||||
const {
|
||||
setFilterAttributes,
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({ filteri18nKey: 'TEST', attributeModel: 'conversation' });
|
||||
|
||||
expect(setFilterAttributes).toBeDefined();
|
||||
expect(initializeStatusAndAssigneeFilterToModal).toBeDefined();
|
||||
expect(initializeInboxTeamAndLabelFilterToModal).toBeDefined();
|
||||
});
|
||||
|
||||
describe('setFilterAttributes', () => {
|
||||
it('should return filterGroups and filterTypes', () => {
|
||||
const mockAttributes = [
|
||||
{
|
||||
attribute_key: 'test_key',
|
||||
attribute_display_name: 'Test Name',
|
||||
attribute_display_type: 'text',
|
||||
},
|
||||
];
|
||||
vi.mocked(useStore)().getters[
|
||||
'attributes/getAttributesByModel'
|
||||
].mockReturnValue(mockAttributes);
|
||||
|
||||
const { setFilterAttributes } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = setFilterAttributes();
|
||||
|
||||
expect(result).toHaveProperty('filterGroups');
|
||||
expect(result).toHaveProperty('filterTypes');
|
||||
expect(result.filterGroups.length).toBeGreaterThan(0);
|
||||
expect(result.filterTypes.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeStatusAndAssigneeFilterToModal', () => {
|
||||
it('should return status filter when activeStatus is provided', () => {
|
||||
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = initializeStatusAndAssigneeFilterToModal('open', {}, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
attribute_key: 'status',
|
||||
attribute_model: 'standard',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{ id: 'open', name: 'CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT' },
|
||||
],
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null when no active filters', () => {
|
||||
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = initializeStatusAndAssigneeFilterToModal('', {}, '');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('initializeInboxTeamAndLabelFilterToModal', () => {
|
||||
it('should return filters for inbox, team, and label when provided', () => {
|
||||
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = initializeInboxTeamAndLabelFilterToModal(
|
||||
1,
|
||||
{ name: 'Inbox 1' },
|
||||
2,
|
||||
[{ id: 2, name: 'Team 2' }],
|
||||
'Label 1'
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
|
||||
expect(result[1]).toHaveProperty('attribute_key', 'team_id');
|
||||
expect(result[2]).toHaveProperty('attribute_key', 'labels');
|
||||
});
|
||||
|
||||
it('should return empty array when no filters are provided', () => {
|
||||
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = initializeInboxTeamAndLabelFilterToModal(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return only inbox filter when only inbox is provided', () => {
|
||||
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
|
||||
filteri18nKey: 'TEST',
|
||||
attributeModel: 'conversation',
|
||||
});
|
||||
const result = initializeInboxTeamAndLabelFilterToModal(
|
||||
1,
|
||||
{ name: 'Inbox 1' },
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useLocale } from '../useLocale';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
vi.mock('vue-i18n');
|
||||
|
||||
describe('useLocale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
locale: ref('en-US'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedLocale', () => {
|
||||
it('should return normalized locale for valid hyphen-based tags', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
expect(resolvedLocale.value).toBe('en-US');
|
||||
});
|
||||
|
||||
it('should normalize underscore-based locale tags to hyphens', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize pt_BR to pt-BR
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should normalize zh_CN to zh-CN or fall back to zh', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize zh_CN to zh-CN or fall back to zh
|
||||
expect(resolvedLocale.value).toMatch(/^zh(-CN)?$/);
|
||||
});
|
||||
|
||||
it('should normalize en_US to en-US or fall back to en', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize en_US to en-US or fall back to en
|
||||
expect(resolvedLocale.value).toMatch(/^en(-US)?$/);
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Use a specific locale that might not be fully supported
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should return either pt-BR or pt (base language)
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle null locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle undefined locale gracefully', () => {
|
||||
const mockLocale = ref(undefined);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle base language code without region', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should work with base language
|
||||
expect(resolvedLocale.value).toBe('pt');
|
||||
});
|
||||
|
||||
it('should handle multiple underscores in locale tag', () => {
|
||||
const mockLocale = ref('zh_Hans_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize all underscores to hyphens
|
||||
expect(resolvedLocale.value).toMatch(/^zh(-Hans-CN|-Hans|-CN)?$/);
|
||||
});
|
||||
|
||||
it('should be reactive to locale changes', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
expect(resolvedLocale.value).toBe('en-US');
|
||||
|
||||
// Change locale
|
||||
mockLocale.value = 'pt_BR';
|
||||
|
||||
// Should update reactively
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should work with common locales', () => {
|
||||
const testCases = [
|
||||
{ input: 'de-DE', expected: /^de(-DE)?$/ },
|
||||
{ input: 'fr-FR', expected: /^fr(-FR)?$/ },
|
||||
{ input: 'es-ES', expected: /^es(-ES)?$/ },
|
||||
{ input: 'ja-JP', expected: /^ja(-JP)?$/ },
|
||||
{ input: 'ko-KR', expected: /^ko(-KR)?$/ },
|
||||
{ input: 'ar-SA', expected: /^ar(-SA)?$/ },
|
||||
{ input: 'hi-IN', expected: /^hi(-IN)?$/ },
|
||||
{ input: 'ru-RU', expected: /^ru(-RU)?$/ },
|
||||
{ input: 'tr-TR', expected: /^tr(-TR)?$/ },
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const mockLocale = ref(input);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
expect(resolvedLocale.value).toMatch(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale (raw)', () => {
|
||||
it('should expose raw locale value', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { locale } = useLocale();
|
||||
// Raw locale should be unchanged
|
||||
expect(locale.value).toBe('pt_BR');
|
||||
});
|
||||
|
||||
it('should be reactive', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { locale } = useLocale();
|
||||
|
||||
expect(locale.value).toBe('en-US');
|
||||
|
||||
mockLocale.value = 'pt-BR';
|
||||
|
||||
expect(locale.value).toBe('pt-BR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Intl API compatibility', () => {
|
||||
it('should work with Intl.NumberFormat', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.NumberFormat(resolvedLocale.value).format(1234567);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with Intl.DateTimeFormat', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with Intl.Collator', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.Collator(resolvedLocale.value).compare('a', 'b');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMessageFormatter } from '../useMessageFormatter';
|
||||
|
||||
describe('useMessageFormatter', () => {
|
||||
let messageFormatter;
|
||||
|
||||
beforeEach(() => {
|
||||
messageFormatter = useMessageFormatter();
|
||||
});
|
||||
|
||||
describe('formatMessage', () => {
|
||||
it('should format a regular message correctly', () => {
|
||||
const message = 'This is a [test](https://example.com) message';
|
||||
const result = messageFormatter.formatMessage(message, false, false);
|
||||
expect(result).toContain('<a href="https://example.com"');
|
||||
expect(result).toContain('class="link"');
|
||||
});
|
||||
|
||||
it('should format a tweet correctly', () => {
|
||||
const message = '@user #hashtag';
|
||||
const result = messageFormatter.formatMessage(message, true, false);
|
||||
expect(result).toContain('<a href="http://twitter.com/user"');
|
||||
expect(result).toContain('<a href="https://twitter.com/hashtag/hashtag"');
|
||||
});
|
||||
|
||||
it('should not format mentions and hashtags for private notes', () => {
|
||||
const message = '@user #hashtag';
|
||||
const result = messageFormatter.formatMessage(message, false, true);
|
||||
expect(result).not.toContain('<a href="http://twitter.com/user"');
|
||||
expect(result).not.toContain(
|
||||
'<a href="https://twitter.com/hashtag/hashtag"'
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable link formatting when linkify is false', () => {
|
||||
const message = 'Check https://example.com and {{user.id}}';
|
||||
const result = messageFormatter.formatMessage(
|
||||
message,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
);
|
||||
expect(result).not.toContain('<a href="https://example.com"');
|
||||
expect(result).toContain('{{user.id}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateMessage', () => {
|
||||
it('should not truncate short messages', () => {
|
||||
const message = 'Short message';
|
||||
const result = messageFormatter.truncateMessage(message);
|
||||
expect(result).toBe(message);
|
||||
});
|
||||
|
||||
it('should truncate long messages', () => {
|
||||
const message = 'A'.repeat(150);
|
||||
const result = messageFormatter.truncateMessage(message);
|
||||
expect(result.length).toBe(100);
|
||||
expect(result.endsWith('...')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightContent', () => {
|
||||
it('should highlight search term in content', () => {
|
||||
const content = 'This is a test message';
|
||||
const searchTerm = 'test';
|
||||
const highlightClass = 'highlight';
|
||||
const result = messageFormatter.highlightContent(
|
||||
content,
|
||||
searchTerm,
|
||||
highlightClass
|
||||
);
|
||||
expect(result.trim()).toBe(
|
||||
'This is a <span class="highlight">test</span> message'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle special characters in search term', () => {
|
||||
const content = 'This (message) contains [special] characters';
|
||||
const searchTerm = '(message)';
|
||||
const highlightClass = 'highlight';
|
||||
const result = messageFormatter.highlightContent(
|
||||
content,
|
||||
searchTerm,
|
||||
highlightClass
|
||||
);
|
||||
expect(result.trim()).toBe(
|
||||
'This <span class="highlight">(message)</span> contains [special] characters'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useNumberFormatter } from '../useNumberFormatter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
vi.mock('vue-i18n');
|
||||
|
||||
describe('useNumberFormatter', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
locale: ref('en-US'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompactNumber', () => {
|
||||
it('should return exact numbers for values under 1,000', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(0)).toBe('0');
|
||||
expect(formatCompactNumber(1)).toBe('1');
|
||||
expect(formatCompactNumber(42)).toBe('42');
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1000)).toBe('1k');
|
||||
expect(formatCompactNumber(1020)).toBe('1k+');
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1999)).toBe('1k+');
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
expect(formatCompactNumber(15000)).toBe('15k');
|
||||
expect(formatCompactNumber(15500)).toBe('15k+');
|
||||
expect(formatCompactNumber(999999)).toBe('999k+');
|
||||
});
|
||||
|
||||
it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
expect(formatCompactNumber(1000001)).toBe('1.0M');
|
||||
expect(formatCompactNumber(1200000)).toBe('1.2M');
|
||||
expect(formatCompactNumber(1234000)).toBe('1.2M');
|
||||
expect(formatCompactNumber(2500000)).toBe('2.5M');
|
||||
expect(formatCompactNumber(10000000)).toBe('10M');
|
||||
expect(formatCompactNumber(1000000000)).toBe('1B');
|
||||
expect(formatCompactNumber(1100000000)).toBe('1.1B');
|
||||
expect(formatCompactNumber(10000000000)).toBe('10B');
|
||||
expect(formatCompactNumber(11000000000)).toBe('11B');
|
||||
expect(formatCompactNumber(1000000000000)).toBe('1T');
|
||||
expect(formatCompactNumber(1100000000000)).toBe('1.1T');
|
||||
expect(formatCompactNumber(10000000000000)).toBe('10T');
|
||||
expect(formatCompactNumber(11000000000000)).toBe('11T');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(null)).toBe('0');
|
||||
expect(formatCompactNumber(undefined)).toBe('0');
|
||||
expect(formatCompactNumber(NaN)).toBe('0');
|
||||
expect(formatCompactNumber('string')).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(-500)).toBe('-500');
|
||||
expect(formatCompactNumber(-1000)).toBe('-1k');
|
||||
expect(formatCompactNumber(-1500)).toBe('-1k+');
|
||||
expect(formatCompactNumber(-2000)).toBe('-2k');
|
||||
expect(formatCompactNumber(-1200000)).toBe('-1.2M');
|
||||
});
|
||||
|
||||
it('should format with en-US locale', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
});
|
||||
|
||||
it('should format with de-DE locale', () => {
|
||||
const mockLocale = ref('de-DE');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
});
|
||||
|
||||
it('should format with fr-FR locale (compact notation)', () => {
|
||||
const mockLocale = ref('fr-FR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(1000000);
|
||||
expect(result).toMatch(/1\s*M/); // French uses space before M
|
||||
});
|
||||
|
||||
it('should format with ja-JP locale', () => {
|
||||
const mockLocale = ref('ja-JP');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format with ar-SA locale (Arabic numerals)', () => {
|
||||
const mockLocale = ref('ar-SA');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(5000);
|
||||
expect(result).toContain('k');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should format with es-ES locale', () => {
|
||||
const mockLocale = ref('es-ES');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(7500)).toBe('7k+');
|
||||
});
|
||||
|
||||
it('should format with hi-IN locale', () => {
|
||||
const mockLocale = ref('hi-IN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(100000)).toBe('100k');
|
||||
});
|
||||
|
||||
it('should format with ru-RU locale', () => {
|
||||
const mockLocale = ref('ru-RU');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(3000)).toBe('3k');
|
||||
});
|
||||
|
||||
it('should format with ko-KR locale (uses 만 for 10,000)', () => {
|
||||
const mockLocale = ref('ko-KR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(2500000);
|
||||
expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
|
||||
});
|
||||
|
||||
it('should format with pt-BR locale', () => {
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(8888)).toBe('8k+');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (pt_BR)', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize pt_BR to pt-BR and work correctly
|
||||
expect(formatCompactNumber(8888)).toBe('8k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1\u00a0mi');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (zh_CN)', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize zh_CN to zh-CN and work correctly
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
expect(formatCompactNumber(5000)).toBe('5k');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (en_US)', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize en_US to en-US and work correctly
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
});
|
||||
|
||||
it('should handle null/undefined locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' locale
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Simulate a case where pt-BR might not be fully supported but pt is
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should work with either pt-BR or pt fallback
|
||||
const result = formatCompactNumber(1500);
|
||||
expect(result).toMatch(/1k\+/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
// Use a completely made-up locale
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' and work
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
});
|
||||
|
||||
it('should handle edge case with only base language code', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should work with base language
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFullNumber', () => {
|
||||
it('should format numbers with locale-specific formatting', () => {
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1000)).toBe('1,000');
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
|
||||
expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(null)).toBe('0');
|
||||
expect(formatFullNumber(undefined)).toBe('0');
|
||||
expect(formatFullNumber(NaN)).toBe('0');
|
||||
expect(formatFullNumber('string')).toBe('0');
|
||||
});
|
||||
|
||||
it('should format with en-US locale (comma separator)', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should format with de-DE locale (period separator)', () => {
|
||||
const mockLocale = ref('de-DE');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(9876543)).toBe('9.876.543');
|
||||
});
|
||||
|
||||
it('should format with es-ES locale (period separator)', () => {
|
||||
const mockLocale = ref('es-ES');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(5555555)).toBe('5.555.555');
|
||||
});
|
||||
|
||||
it('should format with zh-CN locale', () => {
|
||||
const mockLocale = ref('zh-CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1000000)).toBe('1,000,000');
|
||||
});
|
||||
|
||||
it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
|
||||
const mockLocale = ref('ar-EG');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(7654321);
|
||||
// Arabic locale uses Eastern Arabic numerals (٠-٩)
|
||||
// Just verify it's formatted (length should be reasonable)
|
||||
expect(result.length).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it('should format with fr-FR locale (narrow no-break space)', () => {
|
||||
const mockLocale = ref('fr-FR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(3333333);
|
||||
expect(result).toContain('3');
|
||||
expect(result).toContain('333');
|
||||
});
|
||||
|
||||
it('should format with hi-IN locale (Indian numbering system)', () => {
|
||||
const mockLocale = ref('hi-IN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(9999999)).toBe('99,99,999');
|
||||
});
|
||||
|
||||
it('should format with th-TH locale', () => {
|
||||
const mockLocale = ref('th-TH');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(4444444)).toBe('4,444,444');
|
||||
});
|
||||
|
||||
it('should format with tr-TR locale', () => {
|
||||
const mockLocale = ref('tr-TR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(6666666)).toBe('6.666.666');
|
||||
});
|
||||
|
||||
it('should format with pt-PT locale (space separator)', () => {
|
||||
const mockLocale = ref('pt-PT');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(2222222);
|
||||
// Portuguese (Portugal) uses narrow no-break space as separator
|
||||
expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (pt_BR)', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize pt_BR to pt-BR and work correctly
|
||||
expect(formatFullNumber(1234567)).toBe('1.234.567');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (zh_CN)', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize zh_CN to zh-CN and work correctly
|
||||
expect(formatFullNumber(1000000)).toBe('1,000,000');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (en_US)', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize en_US to en-US and work correctly
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should handle null/undefined locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' locale
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Simulate a case where pt-BR might not be fully supported but pt is
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should work with either pt-BR or pt fallback
|
||||
const result = formatFullNumber(1234567);
|
||||
// Portuguese uses period as thousands separator
|
||||
expect(result).toMatch(/1[.,\s]234[.,\s]567/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
// Use a completely made-up locale
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' and work
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should handle edge case with only base language code', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should work with base language
|
||||
const result = formatFullNumber(1000000);
|
||||
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Composable for branding-related utilities
|
||||
* Provides methods to customize text with installation-specific branding
|
||||
*/
|
||||
import { useMapGetter } from 'dashboard/composables/store.js';
|
||||
|
||||
export function useBranding() {
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
/**
|
||||
* Replaces "Chatwoot" in text with the installation name from global config
|
||||
* @param {string} text - The text to process
|
||||
* @returns {string} - Text with "Chatwoot" replaced by installation name
|
||||
*/
|
||||
const replaceInstallationName = text => {
|
||||
if (!text) return text;
|
||||
|
||||
const installationName = globalConfig.value?.installationName;
|
||||
if (!installationName) return text;
|
||||
|
||||
return text.replace(/Chatwoot/g, installationName);
|
||||
};
|
||||
|
||||
return {
|
||||
replaceInstallationName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ref, computed, nextTick, onMounted } from 'vue';
|
||||
import { useToggle, useResizeObserver } from '@vueuse/core';
|
||||
|
||||
/**
|
||||
* Composable for handling expandable content with "Read more / Read less" functionality.
|
||||
* Detects content overflow and provides toggle state for expansion.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} [options.maxLines=2] - Maximum number of lines before showing toggle
|
||||
* @param {number} [options.defaultLineHeight=20] - Fallback line height if computed style is unavailable
|
||||
* @param {boolean} [options.useResizeObserverForCheck=false] - Use ResizeObserver for continuous overflow checking
|
||||
* @returns {Object} - Composable state and methods
|
||||
*/
|
||||
export function useExpandableContent(options = {}) {
|
||||
const {
|
||||
maxLines = 2,
|
||||
defaultLineHeight = 20,
|
||||
useResizeObserverForCheck = false,
|
||||
} = options;
|
||||
|
||||
const contentElement = ref(null);
|
||||
const [isExpanded, toggleExpanded] = useToggle(false);
|
||||
const needsToggle = ref(false);
|
||||
|
||||
const showReadMore = computed(() => needsToggle.value && !isExpanded.value);
|
||||
const showReadLess = computed(() => needsToggle.value && isExpanded.value);
|
||||
|
||||
/**
|
||||
* Checks if content overflows the maximum allowed height
|
||||
* and updates needsToggle accordingly
|
||||
*/
|
||||
const checkOverflow = () => {
|
||||
if (!contentElement.value) return;
|
||||
|
||||
const element = contentElement.value;
|
||||
const computedStyle = window.getComputedStyle(element);
|
||||
const lineHeight =
|
||||
parseFloat(computedStyle.lineHeight) || defaultLineHeight;
|
||||
const maxHeight = lineHeight * maxLines;
|
||||
|
||||
needsToggle.value = element.scrollHeight > maxHeight;
|
||||
};
|
||||
|
||||
// Setup overflow checking based on configuration
|
||||
if (useResizeObserverForCheck) {
|
||||
useResizeObserver(contentElement, checkOverflow);
|
||||
} else {
|
||||
onMounted(() => {
|
||||
nextTick(checkOverflow);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
contentElement,
|
||||
isExpanded,
|
||||
needsToggle,
|
||||
showReadMore,
|
||||
showReadLess,
|
||||
toggleExpanded,
|
||||
checkOverflow,
|
||||
};
|
||||
}
|
||||
181
research/chatwoot/app/javascript/shared/composables/useFilter.js
Normal file
181
research/chatwoot/app/javascript/shared/composables/useFilter.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { filterAttributeGroups as conversationFilterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
|
||||
import { filterAttributeGroups as contactFilterAttributeGroups } from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
|
||||
const customAttributeInputType = key => {
|
||||
switch (key) {
|
||||
case 'date':
|
||||
return 'date';
|
||||
case 'text':
|
||||
return 'plain_text';
|
||||
case 'list':
|
||||
return 'search_select';
|
||||
case 'checkbox':
|
||||
return 'search_select';
|
||||
default:
|
||||
return 'plain_text';
|
||||
}
|
||||
};
|
||||
|
||||
const getOperatorTypes = key => {
|
||||
switch (key) {
|
||||
case 'list':
|
||||
return OPERATORS.OPERATOR_TYPES_1;
|
||||
case 'text':
|
||||
return OPERATORS.OPERATOR_TYPES_3;
|
||||
case 'number':
|
||||
return OPERATORS.OPERATOR_TYPES_1;
|
||||
case 'link':
|
||||
return OPERATORS.OPERATOR_TYPES_1;
|
||||
case 'date':
|
||||
return OPERATORS.OPERATOR_TYPES_4;
|
||||
case 'checkbox':
|
||||
return OPERATORS.OPERATOR_TYPES_1;
|
||||
default:
|
||||
return OPERATORS.OPERATOR_TYPES_1;
|
||||
}
|
||||
};
|
||||
|
||||
export const useFilter = ({ filteri18nKey, attributeModel }) => {
|
||||
const { t: $t } = useI18n();
|
||||
const { getters } = useStore();
|
||||
|
||||
const filterAttributeGroups =
|
||||
attributeModel === 'contact_attribute'
|
||||
? contactFilterAttributeGroups
|
||||
: conversationFilterAttributeGroups;
|
||||
|
||||
const setFilterAttributes = () => {
|
||||
const allCustomAttributes =
|
||||
getters['attributes/getAttributesByModel'](attributeModel);
|
||||
|
||||
const customAttributesFormatted = {
|
||||
name: $t(`${filteri18nKey}.GROUPS.CUSTOM_ATTRIBUTES`),
|
||||
attributes: allCustomAttributes.map(attr => {
|
||||
return {
|
||||
key: attr.attribute_key,
|
||||
name: attr.attribute_display_name,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const allFilterGroups = filterAttributeGroups.map(group => {
|
||||
return {
|
||||
name: $t(`${filteri18nKey}.GROUPS.${group.i18nGroup}`),
|
||||
attributes: group.attributes.map(attribute => {
|
||||
return {
|
||||
key: attribute.key,
|
||||
name: $t(`${filteri18nKey}.ATTRIBUTES.${attribute.i18nKey}`),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const customAttributeTypes = allCustomAttributes.map(attr => {
|
||||
return {
|
||||
attributeKey: attr.attribute_key,
|
||||
attributeI18nKey: `CUSTOM_ATTRIBUTE_${attr.attribute_display_type.toUpperCase()}`,
|
||||
inputType: customAttributeInputType(attr.attribute_display_type),
|
||||
filterOperators: getOperatorTypes(attr.attribute_display_type),
|
||||
attributeModel: 'custom_attributes',
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
filterGroups: [...allFilterGroups, customAttributesFormatted],
|
||||
filterTypes: [...customAttributeTypes],
|
||||
};
|
||||
};
|
||||
|
||||
const initializeStatusAndAssigneeFilterToModal = (
|
||||
activeStatus,
|
||||
currentUserDetails,
|
||||
activeAssigneeTab
|
||||
) => {
|
||||
if (activeStatus !== '') {
|
||||
return {
|
||||
attribute_key: 'status',
|
||||
attribute_model: 'standard',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{
|
||||
id: activeStatus,
|
||||
name: $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`),
|
||||
},
|
||||
],
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
};
|
||||
}
|
||||
if (activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ME) {
|
||||
return {
|
||||
attribute_key: 'assignee_id',
|
||||
filter_operator: 'equal_to',
|
||||
values: currentUserDetails,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const initializeInboxTeamAndLabelFilterToModal = (
|
||||
conversationInbox,
|
||||
inbox,
|
||||
teamId,
|
||||
activeTeam,
|
||||
label
|
||||
) => {
|
||||
const filters = [];
|
||||
if (conversationInbox) {
|
||||
filters.push({
|
||||
attribute_key: 'inbox_id',
|
||||
attribute_model: 'standard',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{
|
||||
id: conversationInbox,
|
||||
name: inbox.name,
|
||||
},
|
||||
],
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
}
|
||||
if (teamId) {
|
||||
filters.push({
|
||||
attribute_key: 'team_id',
|
||||
attribute_model: 'standard',
|
||||
filter_operator: 'equal_to',
|
||||
values: activeTeam,
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
}
|
||||
if (label) {
|
||||
filters.push({
|
||||
attribute_key: 'labels',
|
||||
attribute_model: 'standard',
|
||||
filter_operator: 'equal_to',
|
||||
values: [
|
||||
{
|
||||
id: label,
|
||||
name: label,
|
||||
},
|
||||
],
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
});
|
||||
}
|
||||
return filters;
|
||||
};
|
||||
|
||||
return {
|
||||
setFilterAttributes,
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* Composable for locale resolution and validation
|
||||
* Provides a normalized, validated locale that works with Intl APIs
|
||||
*/
|
||||
export function useLocale() {
|
||||
const { locale } = useI18n();
|
||||
|
||||
/**
|
||||
* Resolves and validates the current locale for use with Intl APIs
|
||||
*
|
||||
* Handles multiple fallback scenarios:
|
||||
* 1. Normalizes underscore-based tags (pt_BR → pt-BR, zh_CN → zh-CN)
|
||||
* 2. Falls back to base language if specific locale unsupported (pt-BR → pt)
|
||||
* 3. Falls back to English if base language unsupported (xx-YY → en)
|
||||
*
|
||||
* @returns {string} Valid BCP 47 locale tag for Intl APIs
|
||||
*
|
||||
* @example
|
||||
* const { resolvedLocale } = useLocale();
|
||||
* new Intl.NumberFormat(resolvedLocale.value).format(1234);
|
||||
* new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
|
||||
*/
|
||||
const resolvedLocale = computed(() => {
|
||||
// Handle null/undefined locale
|
||||
if (!locale.value) return 'en';
|
||||
|
||||
// Normalize underscore to hyphen (pt_BR → pt-BR, zh_CN → zh-CN)
|
||||
const normalized = locale.value.replace(/_/g, '-');
|
||||
|
||||
// Check if the specific locale is supported (e.g., pt-BR, zh-CN)
|
||||
const supportedLocales = Intl.NumberFormat.supportedLocalesOf([normalized]);
|
||||
if (supportedLocales.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// If specific locale not supported, try base language (pt-BR → pt, zh-CN → zh)
|
||||
const baseLocale = normalized.split('-')[0];
|
||||
const baseSupportedLocales = Intl.NumberFormat.supportedLocalesOf([
|
||||
baseLocale,
|
||||
]);
|
||||
if (baseSupportedLocales.length > 0) {
|
||||
return baseLocale;
|
||||
}
|
||||
|
||||
// If base language also not supported, fall back to English
|
||||
return 'en';
|
||||
});
|
||||
|
||||
return {
|
||||
resolvedLocale,
|
||||
// Also expose the raw locale for cases where you need it
|
||||
locale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import MessageFormatter from '../helpers/MessageFormatter';
|
||||
|
||||
/**
|
||||
* A composable providing utility functions for message formatting.
|
||||
*
|
||||
* @returns {Object} A set of functions for message formatting.
|
||||
*/
|
||||
export const useMessageFormatter = () => {
|
||||
/**
|
||||
* Formats a message based on specified conditions.
|
||||
*
|
||||
* @param {string} message - The message to be formatted.
|
||||
* @param {boolean} isATweet - Whether the message is a tweet.
|
||||
* @param {boolean} isAPrivateNote - Whether the message is a private note.
|
||||
* @returns {string} - The formatted message.
|
||||
*/
|
||||
// TODO: ref:https://github.com/chatwoot/chatwoot/pull/10725#discussion_r1925300874
|
||||
const formatMessage = (message, isATweet, isAPrivateNote, linkify) => {
|
||||
const messageFormatter = new MessageFormatter(
|
||||
message,
|
||||
isATweet,
|
||||
isAPrivateNote,
|
||||
linkify
|
||||
);
|
||||
return messageFormatter.formattedMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a message to plain text.
|
||||
*
|
||||
* @param {string} message - The message to be converted.
|
||||
* @param {boolean} isATweet - Whether the message is a tweet.
|
||||
* @returns {string} - The plain text message.
|
||||
*/
|
||||
const getPlainText = (message, isATweet) => {
|
||||
const messageFormatter = new MessageFormatter(message, isATweet);
|
||||
return messageFormatter.plainText;
|
||||
};
|
||||
|
||||
/**
|
||||
* Truncates a description to a maximum length of 100 characters.
|
||||
*
|
||||
* @param {string} [description=''] - The description to be truncated.
|
||||
* @returns {string} - The truncated description.
|
||||
*/
|
||||
const truncateMessage = (description = '') => {
|
||||
if (description.length < 100) {
|
||||
return description;
|
||||
}
|
||||
|
||||
return `${description.slice(0, 97)}...`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Highlights occurrences of a search term within given content.
|
||||
*
|
||||
* @param {string} [content=''] - The content in which to search.
|
||||
* @param {string} [searchTerm=''] - The term to search for.
|
||||
* @param {string} [highlightClass=''] - The CSS class to apply to the highlighted term.
|
||||
* @returns {string} - The content with highlighted terms.
|
||||
*/
|
||||
const highlightContent = (
|
||||
content = '',
|
||||
searchTerm = '',
|
||||
highlightClass = ''
|
||||
) => {
|
||||
const plainTextContent = getPlainText(content);
|
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
|
||||
const escapedSearchTerm = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
return plainTextContent.replace(
|
||||
new RegExp(`(${escapedSearchTerm})`, 'ig'),
|
||||
`<span class="${highlightClass}">$1</span>`
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
formatMessage,
|
||||
getPlainText,
|
||||
truncateMessage,
|
||||
highlightContent,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useLocale } from './useLocale';
|
||||
|
||||
/**
|
||||
* Composable for number formatting with i18n locale support
|
||||
* Provides methods to format numbers in compact and full display formats
|
||||
*/
|
||||
export function useNumberFormatter() {
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
/**
|
||||
* Formats numbers for display with clean, minimal formatting
|
||||
* - Up to 1,000: show exact number (e.g., 999)
|
||||
* - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 → "1k", 1500 → "1k+")
|
||||
* - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 → "1.2M")
|
||||
*
|
||||
* Uses browser-native Intl.NumberFormat with proper i18n locale support
|
||||
*
|
||||
* @param {number} num - The number to format
|
||||
* @returns {string} Formatted number string
|
||||
*/
|
||||
const formatCompactNumber = num => {
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
|
||||
if (Math.abs(num) < 1000) {
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
}
|
||||
|
||||
// For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
|
||||
if (Math.abs(num) >= 1000000) {
|
||||
const millions = num / 1000000;
|
||||
return new Intl.NumberFormat(resolvedLocale.value, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
// For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
|
||||
// For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
|
||||
const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
|
||||
const remainder = Math.abs(num) % 1000;
|
||||
const suffix = remainder === 0 ? 'k' : 'k+';
|
||||
return `${new Intl.NumberFormat(resolvedLocale.value).format(thousands)}${suffix}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number for full display with locale-specific formatting
|
||||
* @param {number} num - The number to format
|
||||
* @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
|
||||
*/
|
||||
const formatFullNumber = num => {
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
};
|
||||
|
||||
return {
|
||||
formatCompactNumber,
|
||||
formatFullNumber,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user