Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
184
research/chatwoot/app/javascript/portal/specs/portal.spec.js
Normal file
184
research/chatwoot/app/javascript/portal/specs/portal.spec.js
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import {
|
||||
InitializationHelpers,
|
||||
openExternalLinksInNewTab,
|
||||
} from '../portalHelpers';
|
||||
|
||||
describe('InitializationHelpers.navigateToLocalePage', () => {
|
||||
let dom;
|
||||
let document;
|
||||
let window;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
'<!DOCTYPE html><html><body><div class="locale-switcher" data-portal-slug="test-slug"><select><option value="en">English</option><option value="fr">French</option></select></div></body></html>',
|
||||
{ url: 'http://localhost/' }
|
||||
);
|
||||
document = dom.window.document;
|
||||
window = dom.window;
|
||||
global.document = document;
|
||||
global.window = window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom = null;
|
||||
document = null;
|
||||
window = null;
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
});
|
||||
|
||||
it('sets up document event listener regardless of locale-switcher existence', () => {
|
||||
document.querySelector('.locale-switcher').remove();
|
||||
const documentSpy = vi.spyOn(document, 'addEventListener');
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
documentSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('adds document-level event listener to handle locale switching', () => {
|
||||
const documentSpy = vi.spyOn(document, 'addEventListener');
|
||||
|
||||
InitializationHelpers.navigateToLocalePage();
|
||||
|
||||
expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function));
|
||||
documentSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('openExternalLinksInNewTab', () => {
|
||||
let dom;
|
||||
let document;
|
||||
let window;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<div id="cw-article-content">
|
||||
<a href="https://external.com" id="external">External</a>
|
||||
<a href="https://app.chatwoot.com/page" id="internal">Internal</a>
|
||||
<a href="https://custom.domain.com/page" id="custom">Custom</a>
|
||||
<a href="https://example.com" id="nested"><code>Code</code><strong>Bold</strong></a>
|
||||
<ul>
|
||||
<li>Visit the preferences centre here > <a href="https://external.com" id="list-link"><strong>https://external.com</strong></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>`,
|
||||
{ url: 'https://app.chatwoot.com/hc/article' }
|
||||
);
|
||||
|
||||
document = dom.window.document;
|
||||
window = dom.window;
|
||||
|
||||
window.portalConfig = {
|
||||
customDomain: 'custom.domain.com',
|
||||
hostURL: 'app.chatwoot.com',
|
||||
};
|
||||
|
||||
global.document = document;
|
||||
global.window = window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
dom = null;
|
||||
document = null;
|
||||
window = null;
|
||||
delete global.document;
|
||||
delete global.window;
|
||||
});
|
||||
|
||||
const simulateClick = selector => {
|
||||
const element = document.querySelector(selector);
|
||||
const event = new window.MouseEvent('click', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
return element.closest('a') || element;
|
||||
};
|
||||
|
||||
it('opens external links in new tab', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('preserves internal links', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
expect(custom.target).not.toBe('_blank');
|
||||
});
|
||||
|
||||
it('handles clicks on nested elements', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
simulateClick('#nested code');
|
||||
simulateClick('#nested strong');
|
||||
|
||||
const link = document.getElementById('nested');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('handles links inside list items with strong tags', () => {
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
// Click on the strong element inside the link in the list
|
||||
simulateClick('#list-link strong');
|
||||
|
||||
const link = document.getElementById('list-link');
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
});
|
||||
|
||||
it('opens external links in a new tab even if customDomain is empty', () => {
|
||||
window = dom.window;
|
||||
window.portalConfig = {
|
||||
hostURL: 'app.chatwoot.com',
|
||||
};
|
||||
|
||||
global.window = window;
|
||||
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
// this will be blank since the configs customDomain is empty
|
||||
// which is a fair expectation
|
||||
expect(custom.target).toBe('_blank');
|
||||
});
|
||||
|
||||
it('opens external links in a new tab even if hostURL is empty', () => {
|
||||
window = dom.window;
|
||||
window.portalConfig = {
|
||||
customDomain: 'custom.domain.com',
|
||||
};
|
||||
|
||||
global.window = window;
|
||||
|
||||
openExternalLinksInNewTab();
|
||||
|
||||
const link = simulateClick('#external');
|
||||
const internal = simulateClick('#internal');
|
||||
const custom = simulateClick('#custom');
|
||||
|
||||
expect(link.target).toBe('_blank');
|
||||
expect(link.rel).toBe('noopener noreferrer');
|
||||
|
||||
expect(internal.target).not.toBe('_blank');
|
||||
expect(custom.target).not.toBe('_blank');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
setPortalHoverColor,
|
||||
removeQueryParamsFromUrl,
|
||||
updateThemeInHeader,
|
||||
switchTheme,
|
||||
initializeThemeHandlers,
|
||||
initializeMediaQueryListener,
|
||||
initializeTheme,
|
||||
} from '../portalThemeHelper.js';
|
||||
import { adjustColorForContrast } from '../../shared/helpers/colorHelper.js';
|
||||
|
||||
describe('portalThemeHelper', () => {
|
||||
let themeToggleButton;
|
||||
let appearanceDropdown;
|
||||
|
||||
beforeEach(() => {
|
||||
themeToggleButton = document.createElement('div');
|
||||
themeToggleButton.id = 'toggle-appearance';
|
||||
document.body.appendChild(themeToggleButton);
|
||||
|
||||
appearanceDropdown = document.createElement('div');
|
||||
appearanceDropdown.id = 'appearance-dropdown';
|
||||
appearanceDropdown.classList.add('appearance-menu');
|
||||
document.body.appendChild(appearanceDropdown);
|
||||
|
||||
window.matchMedia = vi.fn().mockImplementation(query => ({
|
||||
matches: query === '(prefers-color-scheme: dark)',
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
|
||||
window.portalConfig = { portalColor: '#ff5733' };
|
||||
document.documentElement.style.setProperty = vi.fn();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
themeToggleButton.remove();
|
||||
appearanceDropdown.remove();
|
||||
delete window.portalConfig;
|
||||
document.documentElement.style.setProperty.mockRestore();
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('#setPortalHoverColor', () => {
|
||||
it('should apply dark hover color in dark theme', () => {
|
||||
const hoverColor = adjustColorForContrast('#ff5733', '#151718');
|
||||
setPortalHoverColor('dark');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--dynamic-hover-color',
|
||||
hoverColor
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply light hover color in light theme', () => {
|
||||
const hoverColor = adjustColorForContrast('#ff5733', '#ffffff');
|
||||
setPortalHoverColor('light');
|
||||
expect(document.documentElement.style.setProperty).toHaveBeenCalledWith(
|
||||
'--dynamic-hover-color',
|
||||
hoverColor
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removeQueryParamsFromUrl', () => {
|
||||
let originalLocation;
|
||||
|
||||
beforeEach(() => {
|
||||
originalLocation = window.location;
|
||||
delete window.location;
|
||||
window.location = new URL('http://localhost:3000/');
|
||||
window.history.replaceState = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
it('should not remove query params if theme is not in the URL', () => {
|
||||
removeQueryParamsFromUrl();
|
||||
expect(window.history.replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should remove theme query param from the URL', () => {
|
||||
window.location = new URL(
|
||||
'http://localhost:3000/?theme=light&show_plain_layout=true'
|
||||
);
|
||||
removeQueryParamsFromUrl('theme');
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
{},
|
||||
'',
|
||||
'http://localhost:3000/?show_plain_layout=true'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateThemeInHeader', () => {
|
||||
beforeEach(() => {
|
||||
themeToggleButton.innerHTML = `
|
||||
<div class="theme-button" data-theme="light"></div>
|
||||
<div class="theme-button" data-theme="dark"></div>
|
||||
<div class="theme-button" data-theme="system"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it('should not update header if theme toggle button is not found', () => {
|
||||
themeToggleButton.remove();
|
||||
updateThemeInHeader('light');
|
||||
expect(document.querySelector('.theme-button')).toBeNull();
|
||||
});
|
||||
|
||||
it('should show the theme button for the selected theme', () => {
|
||||
updateThemeInHeader('light');
|
||||
const lightButton = themeToggleButton.querySelector(
|
||||
'.theme-button[data-theme="light"]'
|
||||
);
|
||||
expect(lightButton.classList).toContain('flex');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#switchTheme', () => {
|
||||
it('should set theme to system theme and update classes', () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
|
||||
switchTheme('system');
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
|
||||
it('should set theme to light theme and update classes', () => {
|
||||
switchTheme('light');
|
||||
expect(localStorage.theme).toBe('light');
|
||||
expect(document.documentElement.classList).toContain('light');
|
||||
});
|
||||
|
||||
it('should set theme to dark theme and update classes', () => {
|
||||
switchTheme('dark');
|
||||
expect(localStorage.theme).toBe('dark');
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeThemeHandlers', () => {
|
||||
beforeEach(() => {
|
||||
appearanceDropdown.innerHTML = `
|
||||
<button data-theme="light"><span class="check-mark-icon light-theme"></span></button>
|
||||
<button data-theme="dark"><span class="check-mark-icon dark-theme"></span></button>
|
||||
<button data-theme="system"><span class="check-mark-icon system-theme"></span></button>
|
||||
`;
|
||||
});
|
||||
|
||||
it('does nothing if the appearance dropdown is not found', () => {
|
||||
appearanceDropdown.remove();
|
||||
expect(() => initializeThemeHandlers()).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle theme button clicks', () => {
|
||||
initializeThemeHandlers();
|
||||
|
||||
// Simulate clicking a theme button
|
||||
const lightButton = appearanceDropdown.querySelector(
|
||||
'button[data-theme="light"]'
|
||||
);
|
||||
const clickEvent = new Event('click', { bubbles: true });
|
||||
Object.defineProperty(clickEvent, 'target', {
|
||||
value: lightButton,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
document.dispatchEvent(clickEvent);
|
||||
|
||||
expect(localStorage.theme).toBe('light');
|
||||
expect(appearanceDropdown.dataset.currentTheme).toBe('light');
|
||||
});
|
||||
|
||||
it('should toggle dropdown visibility on toggle button click', () => {
|
||||
initializeThemeHandlers();
|
||||
|
||||
// Initially closed
|
||||
expect(appearanceDropdown.dataset.dropdownOpen).toBeUndefined();
|
||||
|
||||
// Click to open
|
||||
themeToggleButton.click();
|
||||
expect(appearanceDropdown.dataset.dropdownOpen).toBe('true');
|
||||
|
||||
// Click to close
|
||||
themeToggleButton.click();
|
||||
expect(appearanceDropdown.dataset.dropdownOpen).toBe('false');
|
||||
});
|
||||
|
||||
it('should close dropdown when clicking outside', () => {
|
||||
initializeThemeHandlers();
|
||||
|
||||
// Open dropdown
|
||||
appearanceDropdown.dataset.dropdownOpen = 'true';
|
||||
|
||||
// Click outside
|
||||
const outsideClick = new Event('click', { bubbles: true });
|
||||
Object.defineProperty(outsideClick, 'target', {
|
||||
value: document.body,
|
||||
enumerable: true,
|
||||
});
|
||||
document.dispatchEvent(outsideClick);
|
||||
|
||||
expect(appearanceDropdown.dataset.dropdownOpen).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeMediaQueryListener', () => {
|
||||
let mediaQuery;
|
||||
|
||||
beforeEach(() => {
|
||||
mediaQuery = {
|
||||
addEventListener: vi.fn(),
|
||||
matches: false,
|
||||
};
|
||||
window.matchMedia = vi.fn().mockReturnValue(mediaQuery);
|
||||
});
|
||||
|
||||
it('adds a listener to the media query', () => {
|
||||
initializeMediaQueryListener();
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(
|
||||
'(prefers-color-scheme: dark)'
|
||||
);
|
||||
expect(mediaQuery.addEventListener).toHaveBeenCalledWith(
|
||||
'change',
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('does not switch theme if local storage theme is light or dark', () => {
|
||||
localStorage.theme = 'light';
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = true;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(localStorage.theme).toBe('light');
|
||||
});
|
||||
|
||||
it('switches to dark theme if system preference changes to dark and no theme is set in local storage', () => {
|
||||
localStorage.removeItem('theme');
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = true;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(document.documentElement.classList).toContain('dark');
|
||||
});
|
||||
|
||||
it('switches to light theme if system preference changes to light and no theme is set in local storage', () => {
|
||||
localStorage.removeItem('theme');
|
||||
initializeMediaQueryListener();
|
||||
mediaQuery.matches = false;
|
||||
mediaQuery.addEventListener.mock.calls[0][1]();
|
||||
expect(document.documentElement.classList).toContain('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initializeTheme', () => {
|
||||
it('should not initialize theme if plain layout is enabled', () => {
|
||||
window.portalConfig.isPlainLayoutEnabled = 'true';
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
expect(document.documentElement.classList).not.toContain('light');
|
||||
expect(document.documentElement.classList).not.toContain('dark');
|
||||
});
|
||||
|
||||
it('sets the theme to the system theme', () => {
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBeUndefined();
|
||||
const prefersDarkMode = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
expect(document.documentElement.classList.contains('light')).toBe(
|
||||
!prefersDarkMode
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the theme to the light theme', () => {
|
||||
localStorage.theme = 'light';
|
||||
document.documentElement.classList.add('light');
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBe('light');
|
||||
expect(document.documentElement.classList.contains('light')).toBe(true);
|
||||
});
|
||||
|
||||
it('sets the theme to the dark theme', () => {
|
||||
localStorage.theme = 'dark';
|
||||
document.documentElement.classList.add('dark');
|
||||
initializeTheme();
|
||||
expect(localStorage.theme).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user