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,14 @@
import axios from 'axios';
class ArticlesAPI {
constructor() {
this.baseUrl = '';
}
searchArticles(portalSlug, locale, query) {
let baseUrl = `${this.baseUrl}/hc/${portalSlug}/${locale}/articles.json?query=${query}`;
return axios.get(baseUrl);
}
}
export default new ArticlesAPI();

View File

@@ -0,0 +1,51 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@import 'widget/assets/scss/reset';
@import 'shared/assets/fonts/InterDisplay/inter-display';
html,
body {
font-family:
'InterDisplay',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif,
'Noto Sans',
'Apple Color Emoji',
'Segoe UI Emoji',
'Noto Color Emoji';
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
letter-spacing: 0.2px;
}
// Taking these utils from tailwind 3.x.x, need to remove once we upgrade
.scroll-mt-24 {
scroll-margin-top: 6rem;
}
.top-24 {
top: 6rem;
}
.heading {
.permalink {
visibility: hidden;
}
&:hover {
.permalink {
visibility: visible;
}
}
}

View File

@@ -0,0 +1,120 @@
<script>
import SearchSuggestions from './SearchSuggestions.vue';
import PublicSearchInput from './PublicSearchInput.vue';
import ArticlesAPI from '../api/article';
export default {
components: {
PublicSearchInput,
SearchSuggestions,
},
emits: ['input', 'blur'],
data() {
return {
searchTerm: '',
isLoading: false,
showSearchBox: false,
searchResults: [],
};
},
computed: {
portalSlug() {
return window.portalConfig.portalSlug;
},
localeCode() {
return window.portalConfig.localeCode;
},
shouldShowSearchBox() {
return this.searchTerm !== '' && this.showSearchBox;
},
searchTranslations() {
const { searchTranslations = {} } = window.portalConfig;
return searchTranslations;
},
},
watch: {
currentPage() {
this.clearSearchTerm();
},
},
unmounted() {
clearTimeout(this.typingTimer);
},
methods: {
onUpdateSearchTerm(value) {
this.searchTerm = value;
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
this.openSearch();
this.isLoading = true;
this.typingTimer = setTimeout(() => {
this.fetchArticlesByQuery();
}, 1000);
},
onChange(e) {
this.$emit('input', e.target.value);
},
onBlur(e) {
this.$emit('blur', e.target.value);
},
openSearch() {
this.showSearchBox = true;
},
closeSearch() {
this.showSearchBox = false;
},
clearSearchTerm() {
this.searchTerm = '';
},
async fetchArticlesByQuery() {
try {
this.isLoading = true;
this.searchResults = [];
const { data } = await ArticlesAPI.searchArticles(
this.portalSlug,
this.localeCode,
this.searchTerm
);
this.searchResults = data.payload;
this.isLoading = true;
} catch (error) {
// Show something wrong message
} finally {
this.isLoading = false;
}
},
},
};
</script>
<template>
<div v-on-clickaway="closeSearch" class="relative w-full max-w-5xl my-4">
<PublicSearchInput
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
@update:search-term="onUpdateSearchTerm"
@focus="openSearch"
/>
<div
v-if="shouldShowSearchBox"
class="absolute w-full top-14"
@mouseover="openSearch"
>
<SearchSuggestions
:items="searchResults"
:is-loading="isLoading"
:search-term="searchTerm"
:empty-placeholder="searchTranslations.emptyPlaceholder"
:results-title="searchTranslations.resultsTitle"
:loading-placeholder="searchTranslations.loadingPlaceholder"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { ref } from 'vue';
defineProps({
searchTerm: {
type: [String, Number],
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:searchTerm', 'focus', 'blur']);
const isFocused = ref(false);
const onChange = e => {
emit('update:searchTerm', e.target.value);
};
const onFocus = e => {
isFocused.value = true;
emit('focus', e.target.value);
};
const onBlur = e => {
isFocused.value = false;
emit('blur', e.target.value);
};
</script>
<template>
<div
class="w-full flex items-center rounded-lg border-solid border h-12 bg-white dark:bg-slate-900 px-5 py-2 text-slate-600 dark:text-slate-200"
:class="{
'shadow border-woot-100 dark:border-woot-700': isFocused,
'border-slate-50 dark:border-slate-800 shadow-sm': !isFocused,
}"
>
<FluentIcon icon="search" />
<input
:value="searchTerm"
type="text"
class="w-full focus:outline-none text-base h-full bg-white dark:bg-slate-900 px-2 py-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
:placeholder="searchPlaceholder"
role="search"
@input="onChange"
@focus="onFocus"
@blur="onBlur"
/>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script>
import { ref, computed, nextTick } from 'vue';
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
props: {
items: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
emptyPlaceholder: {
type: String,
default: '',
},
loadingPlaceholder: {
type: String,
default: '',
},
searchTerm: {
type: String,
default: '',
},
},
setup(props) {
const selectedIndex = ref(-1);
const portalSearchSuggestionsRef = ref(null);
const { highlightContent } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
});
};
const isSearchItemActive = index => {
return index === selectedIndex.value
? 'bg-slate-25 dark:bg-slate-800'
: 'bg-white dark:bg-slate-900';
};
useKeyboardNavigableList({
items: computed(() => props.items),
adjustScroll,
selectedIndex,
});
return {
selectedIndex,
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
};
},
computed: {
showEmptyResults() {
return !this.items.length && !this.isLoading;
},
shouldShowResults() {
return this.items.length && !this.isLoading;
},
},
methods: {
generateArticleUrl(article) {
return `/hc/${article.portal.slug}/articles/${article.slug}`;
},
prepareContent(content) {
return this.highlightContent(
content,
this.searchTerm,
'bg-slate-100 dark:bg-slate-700 font-semibold text-slate-600 dark:text-slate-200'
);
},
},
};
</script>
<template>
<div
ref="portalSearchSuggestionsRef"
class="p-5 mt-2 overflow-y-auto text-sm bg-white border border-solid rounded-lg shadow-xl hover:shadow-lg dark:bg-slate-900 max-h-96 scroll-py-2 text-slate-700 dark:text-slate-100 border-slate-50 dark:border-slate-800"
>
<div
v-if="isLoading"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
{{ loadingPlaceholder }}
</div>
<ul
v-if="shouldShowResults"
class="flex flex-col gap-4 text-sm bg-white dark:bg-slate-900 text-slate-700 dark:text-slate-100"
role="listbox"
>
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="flex items-center p-4 border border-solid rounded-lg cursor-pointer select-none group hover:bg-slate-25 dark:hover:bg-slate-800 border-slate-100 dark:border-slate-800"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
@mouse-enter="onHover(index)"
@mouse-leave="onHover(-1)"
>
<a
class="flex flex-col gap-1 overflow-y-hidden"
:href="generateArticleUrl(article)"
>
<span
v-dompurify-html="prepareContent(article.title)"
class="flex-auto w-full overflow-hidden text-base font-semibold leading-6 truncate text-ellipsis whitespace-nowrap"
/>
<div
v-dompurify-html="prepareContent(article.content)"
class="overflow-hidden text-sm line-clamp-2 text-ellipsis whitespace-nowrap text-slate-600 dark:text-slate-300"
/>
</a>
</li>
</ul>
<div
v-if="showEmptyResults"
class="text-sm font-medium text-slate-400 dark:text-slate-700"
>
{{ emptyPlaceholder }}
</div>
</div>
</template>

View File

@@ -0,0 +1,127 @@
<script>
export default {
props: {
rows: {
type: Array,
default: () => [],
},
},
data() {
return {
currentSlug: window.location?.hash?.substring(1) || '',
intersectionObserver: null,
};
},
computed: {
h1Count() {
return this.rows.filter(el => el.tag === 'h1').length;
},
h2Count() {
return this.rows.filter(el => el.tag === 'h2').length;
},
},
mounted() {
this.initializeIntersectionObserver();
window.addEventListener('hashchange', this.onURLHashChange);
},
unmounted() {
window.removeEventListener('hashchange', this.onURLHashChange);
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
},
methods: {
getClassName(el) {
if (el.tag === 'h1') {
return '';
}
if (el.tag === 'h2') {
if (this.h1Count > 0) {
return 'ltr:ml-2 rtl:mr-2';
}
return '';
}
if (el.tag === 'h3') {
if (!this.h1Count && !this.h2Count) {
return '';
}
return 'ltr:ml-5 rtl:mr-5';
}
return '';
},
initializeIntersectionObserver() {
this.intersectionObserver = new IntersectionObserver(
entries => {
const currentSection = entries.find(entry => entry.isIntersecting);
if (currentSection) {
this.currentSlug = currentSection.target.id;
}
},
{
threshold: 0.25,
rootMargin: '0px 0px -20% 0px',
}
);
this.rows.forEach(el => {
const sectionElement = document.getElementById(el.slug);
if (!sectionElement) return;
this.intersectionObserver.observe(sectionElement);
});
},
onURLHashChange() {
this.currentSlug = window.location?.hash?.substring(1) || '';
},
isElementActive(el) {
return this.currentSlug === el.slug;
},
elementBorderStyles(el) {
if (this.isElementActive(el)) {
return 'border-slate-400 dark:border-slate-50 transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'text-slate-900 dark:text-slate-25 transition-colors duration-200';
}
return 'text-slate-700 dark:text-slate-100';
},
},
};
</script>
<template>
<div
class="hidden lg:block flex-1 py-6 scroll-mt-24 ltr:pl-4 rtl:pr-4 sticky top-24"
>
<div v-if="rows.length > 0" class="py-2 overflow-auto">
<nav class="max-w-2xl">
<ol
role="list"
class="flex flex-col gap-2 text-base ltr:border-l-2 rtl:border-r-2 border-solid border-slate-100 dark:border-slate-800"
>
<li
v-for="element in rows"
:key="element.slug"
class="leading-6 ltr:border-l-2 rtl:border-r-2 relative ltr:-left-0.5 rtl:-right-0.5 border-solid"
:class="elementBorderStyles(element)"
>
<p class="py-1 px-3" :class="getClassName(element)">
<a
:href="`#${element.slug}`"
data-turbolinks="false"
class="font-medium text-sm tracking-[0.28px] cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
</a>
</p>
</li>
</ol>
</nav>
</div>
</div>
</template>

View File

@@ -0,0 +1,162 @@
import { createApp } from 'vue';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import { domPurifyConfig } from '../shared/helpers/HTMLSanitizer';
import { directive as onClickaway } from 'vue3-click-away';
import { isSameHost } from '@chatwoot/utils';
import slugifyWithCounter from '@sindresorhus/slugify';
import PublicArticleSearch from './components/PublicArticleSearch.vue';
import TableOfContents from './components/TableOfContents.vue';
import { initializeTheme } from './portalThemeHelper.js';
import { getLanguageDirection } from 'dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
export const getHeadingsfromTheArticle = () => {
const rows = [];
const articleElement = document.getElementById('cw-article-content');
articleElement.querySelectorAll('h1, h2, h3').forEach(element => {
const headingText = element.innerText;
const slug = slugifyWithCounter(headingText);
element.id = slug;
element.className = 'scroll-mt-24 heading';
const permalink = document.createElement('a');
permalink.className = 'permalink text-slate-600 ml-3';
permalink.href = `#${slug}`;
permalink.title = headingText;
permalink.dataset.turbolinks = 'false';
permalink.textContent = '#';
element.appendChild(permalink);
rows.push({
slug,
title: headingText,
tag: element.tagName.toLowerCase(),
});
});
return rows;
};
export const openExternalLinksInNewTab = () => {
const { customDomain, hostURL } = window.portalConfig;
const isOnArticlePage =
document.querySelector('#cw-article-content') !== null;
document.addEventListener('click', event => {
if (!isOnArticlePage) return;
const link = event.target.closest('a');
if (link) {
const currentLocation = window.location.href;
const linkHref = link.href;
// Check against current location and custom domains
const isInternalLink =
isSameHost(linkHref, currentLocation) ||
(customDomain && isSameHost(linkHref, customDomain)) ||
(hostURL && isSameHost(linkHref, hostURL));
if (!isInternalLink) {
link.target = '_blank';
link.rel = 'noopener noreferrer'; // Security and performance benefits
// Prevent default if you want to stop the link from opening in the current tab
event.stopPropagation();
}
}
});
};
export const InitializationHelpers = {
navigateToLocalePage: () => {
document.addEventListener('change', e => {
const localeSwitcher = e.target.closest('.locale-switcher');
if (!localeSwitcher) return;
const { portalSlug } = localeSwitcher.dataset;
window.location.href = `/hc/${encodeURIComponent(portalSlug)}/${encodeURIComponent(localeSwitcher.value)}/`;
});
},
initializeSearch: () => {
const isSearchContainerAvailable = document.querySelector('#search-wrap');
if (isSearchContainerAvailable) {
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
template: '<PublicArticleSearch />',
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
app.mount('#search-wrap');
}
},
initializeTableOfContents: () => {
const isOnArticlePage = document.querySelector('#cw-hc-toc');
if (isOnArticlePage) {
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { TableOfContents },
data() {
return { rows: getHeadingsfromTheArticle() };
},
template: '<table-of-contents :rows="rows" />',
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.mount('#cw-hc-toc');
}
},
appendPlainParamToURLs: () => {
[...document.getElementsByTagName('a')].forEach(aTagElement => {
if (aTagElement.href && aTagElement.href.includes('/hc/')) {
const url = new URL(aTagElement.href);
url.searchParams.set('show_plain_layout', 'true');
aTagElement.setAttribute('href', url);
}
});
},
setDirectionAttribute: () => {
const htmlElement = document.querySelector('html');
// If direction is already applied through props, do not apply again (iframe case)
const hasDirApplied = htmlElement.getAttribute('data-dir-applied');
if (!htmlElement || hasDirApplied) return;
const localeFromHtml = htmlElement.lang;
htmlElement.dir =
localeFromHtml && getLanguageDirection(localeFromHtml) ? 'rtl' : 'ltr';
},
initializeThemesInPortal: initializeTheme,
initialize: () => {
openExternalLinksInNewTab();
InitializationHelpers.setDirectionAttribute();
if (window.portalConfig.isPlainLayoutEnabled === 'true') {
InitializationHelpers.appendPlainParamToURLs();
} else {
InitializationHelpers.initializeThemesInPortal();
InitializationHelpers.navigateToLocalePage();
InitializationHelpers.initializeSearch();
InitializationHelpers.initializeTableOfContents();
}
},
onLoad: () => {
InitializationHelpers.initialize();
if (window.location.hash) {
if ('scrollRestoration' in window.history) {
window.history.scrollRestoration = 'manual';
}
const a = document.createElement('a');
a.href = window.location.hash;
a['data-turbolinks'] = false;
a.click();
}
},
};

View File

@@ -0,0 +1,143 @@
import { adjustColorForContrast } from '../shared/helpers/colorHelper.js';
const getResolvedTheme = theme => {
// Helper to get resolved theme (handles 'system' -> 'dark'/'light')
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
return theme;
};
export const setPortalHoverColor = theme => {
// This function is to set the hover color for the portal
const resolvedTheme = getResolvedTheme(theme);
const portalColor = window.portalConfig.portalColor;
const bgColor = resolvedTheme === 'dark' ? '#151718' : 'white';
const hoverColor = adjustColorForContrast(portalColor, bgColor);
// Set hover color for border and text dynamically
document.documentElement.style.setProperty(
'--dynamic-hover-color',
hoverColor
);
};
export const removeQueryParamsFromUrl = (queryParam = 'theme') => {
// This function is to remove the theme query param from the URL
// This is done so that the theme is not persisted in the URL
// This is called when the theme is switched from the dropdown
const url = new URL(window.location.href);
const param = url.searchParams.get(queryParam);
if (param) {
url.searchParams.delete(queryParam);
window.history.replaceState({}, '', url.toString()); // Convert URL to string
}
};
export const updateThemeInHeader = theme => {
// This function is to update the theme selection in the header in real time
const themeToggleButton = document.getElementById('toggle-appearance');
if (!themeToggleButton) return;
const allThemeButtons = themeToggleButton.querySelectorAll('.theme-button');
if (!allThemeButtons.length) return;
allThemeButtons.forEach(button => {
const isActive = button.dataset.theme === theme;
button.classList.toggle('hidden', !isActive);
button.classList.toggle('flex', isActive);
});
};
export const switchTheme = theme => {
// Update localStorage
if (theme === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.theme = theme;
}
const resolvedTheme = getResolvedTheme(theme);
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(resolvedTheme);
setPortalHoverColor(theme);
updateThemeInHeader(theme);
removeQueryParamsFromUrl();
// Update both dropdown data attributes
document.querySelectorAll('.appearance-menu').forEach(menu => {
menu.dataset.currentTheme = theme;
});
};
export const initializeThemeHandlers = () => {
const toggle = document.getElementById('toggle-appearance');
const dropdown = document.getElementById('appearance-dropdown');
if (!toggle || !dropdown) return;
// Toggle appearance dropdown
toggle.addEventListener('click', e => {
e.stopPropagation();
dropdown.dataset.dropdownOpen = String(
dropdown.dataset.dropdownOpen !== 'true'
);
});
document.addEventListener('click', ({ target }) => {
if (toggle.contains(target)) return;
const themeBtn = target.closest('.appearance-menu button[data-theme]');
const menu = themeBtn?.closest('.appearance-menu');
if (themeBtn && menu) {
switchTheme(themeBtn.dataset.theme);
menu.dataset.dropdownOpen = 'false';
if (menu.id === 'mobile-appearance-dropdown') {
// Set the mobile menu toggle to false after a delay to ensure the transition is completed
setTimeout(() => {
const mobileToggle = document.getElementById('mobile-menu-toggle');
if (mobileToggle) mobileToggle.checked = false;
}, 300);
}
return;
}
// Close the desktop appearance dropdown if clicked outside
if (
dropdown.dataset.dropdownOpen === 'true' &&
!dropdown.contains(target)
) {
dropdown.dataset.dropdownOpen = 'false';
}
});
};
export const initializeMediaQueryListener = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', () => {
if (['light', 'dark'].includes(localStorage.theme)) return;
switchTheme('system');
});
};
export const initializeTheme = () => {
if (window.portalConfig.isPlainLayoutEnabled === 'true') return;
// start with updating the theme in the header, this will set the current theme on the button
// and set the hover color at the start of init, this is set again when the theme is switched
switchTheme(localStorage.theme || 'system');
window.updateThemeInHeader = updateThemeInHeader;
// add the event listeners for the dropdown toggle and theme buttons
initializeThemeHandlers();
// add the media query listener to update the theme when the system theme changes
initializeMediaQueryListener();
};

View 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 &gt; <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');
});
});

View File

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