Vendor Chatwoot Vue 3 frontend into frontend/

Copy the Chatwoot (v4.14.0) frontend runnable subset into frontend/ for
customization:
- app/javascript/ (Vue SPA: dashboard, widget, sdk, portal, superadmin)
- app/views/ (ERB templates for vite-plugin-ruby entrypoint resolution)
- app/helpers/, app/assets/ (Rails view helpers, static assets)
- enterprise/ (Enterprise edition frontend overlay)
- config/vite.json, vite.config.ts, bin/vite (Vite-Rails toolchain)
- package.json, pnpm-lock.yaml, tailwind/postcss/eslint configs
- Gemfile, Gemfile.lock (vite_rails gem for bin/vite binstub)

Excluded Rails backend: controllers, models, services, jobs, mailers,
policies, db, lib, spec, public, node_modules.

Update references to the new frontend location:
- .gitignore: exclude frontend build artifacts (node_modules, tmp, packs),
  keep frontend/bin/ and frontend/vendor/ via negation
- backend/scripts/parity_frontend_smoke.sh: CHATWOOT_DIR default
  reference/chatwoot -> ../frontend
- backend/scripts/parity_frontend_browser_smoke.mjs: same default update
- AGENTS.md: add frontend section with Rails/Vite coupling notes
- README: architecture tree includes frontend/
This commit is contained in:
2026-07-07 14:56:01 +08:00
parent aeddedf2a3
commit 321c61aaae
5737 changed files with 945861 additions and 3 deletions
@@ -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();
@@ -0,0 +1,60 @@
// 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';
@import 'shared/assets/fonts/inter';
@import 'dashboard/assets/scss/next-colors';
html,
body {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
letter-spacing: 0.2px;
}
.font-default {
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';
}
// 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;
}
}
}
.turbolinks-progress-bar {
background-color: var(--dynamic-portal-color);
}
@@ -0,0 +1,171 @@
<script>
import SearchSuggestions from './SearchSuggestions.vue';
import PublicSearchInput from './PublicSearchInput.vue';
import ArticlesAPI from '../api/article';
export default {
components: {
PublicSearchInput,
SearchSuggestions,
},
props: {
size: {
type: String,
default: 'default',
validator: value => ['small', 'default'].includes(value),
},
showKbd: {
type: Boolean,
default: false,
},
},
emits: ['input', 'blur'],
data() {
return {
searchTerm: '',
isLoading: false,
showSearchBox: false,
searchResults: [],
};
},
computed: {
portalSlug() {
return window.portalConfig.portalSlug;
},
localeCode() {
return window.portalConfig.localeCode;
},
normalizedSearchTerm() {
return this.searchTerm.trim();
},
shouldShowSearchBox() {
return this.normalizedSearchTerm !== '' && this.showSearchBox;
},
searchTranslations() {
const { searchTranslations = {} } = window.portalConfig;
return searchTranslations;
},
kbdLabel() {
if (!this.showKbd) return '';
const isMac = /Mac|iPhone|iPad|iPod/i.test(
navigator.platform || navigator.userAgent
);
return isMac ? '⌘ K' : 'Ctrl K';
},
},
watch: {
currentPage() {
this.clearSearchTerm();
},
},
mounted() {
if (this.showKbd) document.addEventListener('keydown', this.onKeydown);
},
unmounted() {
if (this.showKbd) document.removeEventListener('keydown', this.onKeydown);
clearTimeout(this.typingTimer);
},
methods: {
onUpdateSearchTerm(value) {
this.searchTerm = value;
if (this.typingTimer) {
clearTimeout(this.typingTimer);
}
if (this.normalizedSearchTerm === '') {
this.searchResults = [];
this.isLoading = false;
this.closeSearch();
return;
}
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 = '';
},
onKeydown(e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
if (this.$refs.searchInput) this.$refs.searchInput.focusInput();
}
if (e.key === 'Escape') {
this.closeSearch();
if (this.$refs.searchInput) this.$refs.searchInput.blurInput();
}
},
async fetchArticlesByQuery() {
const query = this.normalizedSearchTerm;
if (!query) {
this.isLoading = false;
return;
}
try {
this.isLoading = true;
this.searchResults = [];
const { data } = await ArticlesAPI.searchArticles(
this.portalSlug,
this.localeCode,
query
);
this.searchResults = data.payload;
} 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
ref="searchInput"
:search-term="searchTerm"
:search-placeholder="searchTranslations.searchPlaceholder"
:size="size"
:kbd="kbdLabel"
@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="normalizedSearchTerm"
:empty-placeholder="searchTranslations.emptyPlaceholder"
:results-title="searchTranslations.resultsTitle"
:loading-placeholder="searchTranslations.loadingPlaceholder"
/>
</div>
</div>
</template>
@@ -0,0 +1,97 @@
<script setup>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { ref, computed } from 'vue';
const props = defineProps({
searchTerm: {
type: [String, Number],
default: '',
},
searchPlaceholder: {
type: String,
default: '',
},
size: {
type: String,
default: 'default', // 'small' | 'default'
validator: value => ['small', 'default'].includes(value),
},
kbd: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:searchTerm', 'focus', 'blur']);
const isFocused = ref(false);
const inputRef = ref(null);
const sizeClasses = computed(() => {
if (props.size === 'small') {
return {
wrapper: 'h-9 px-3 py-1',
input: 'text-sm py-0',
};
}
return {
wrapper: 'h-12 px-5 py-2',
input: 'text-base py-2',
};
});
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);
};
const focusInput = () => {
if (inputRef.value) inputRef.value.focus();
};
const blurInput = () => {
if (inputRef.value) inputRef.value.blur();
};
defineExpose({ focusInput, blurInput });
</script>
<template>
<div
class="w-full flex items-center gap-2 rounded-lg border-solid border bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 transition-all duration-500 ease-out"
:class="[
sizeClasses.wrapper,
isFocused
? 'shadow border-n-portal ring-4 ring-n-portal-soft'
: 'border-slate-50 dark:border-slate-800 shadow-sm ring-0 ring-transparent',
]"
>
<FluentIcon icon="search" />
<input
ref="inputRef"
:value="searchTerm"
type="text"
class="flex-1 min-w-0 focus:outline-none h-full bg-transparent px-2 text-slate-700 dark:text-slate-100 placeholder-slate-500"
:class="sizeClasses.input"
:placeholder="searchPlaceholder"
role="search"
@input="onChange"
@focus="onFocus"
@blur="onBlur"
/>
<kbd
v-if="kbd"
class="shrink-0 inline-flex items-center text-xs font-medium text-n-slate-11 bg-n-alpha-2 border border-solid border-n-weak rounded px-1.5 py-0.5 font-inter"
>
{{ kbd }}
</kbd>
</div>
</template>
@@ -0,0 +1,126 @@
<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, getPlainText } = useMessageFormatter();
const adjustScroll = () => {
nextTick(() => {
portalSearchSuggestionsRef.value.scrollTop = 102 * selectedIndex.value;
});
};
const isSearchItemActive = index => {
return index === selectedIndex.value ? 'bg-n-portal-soft' : '';
};
useKeyboardNavigableList({
items: computed(() => props.items),
adjustScroll,
selectedIndex,
});
return {
selectedIndex,
portalSearchSuggestionsRef,
isSearchItemActive,
highlightContent,
getPlainText,
};
},
computed: {
showEmptyResults() {
return !this.items.length && !this.isLoading;
},
shouldShowResults() {
return this.items.length && !this.isLoading;
},
},
methods: {
prepareContent(content) {
return this.highlightContent(
content,
this.searchTerm,
'bg-n-portal-soft text-n-portal font-semibold rounded-sm px-1'
);
},
},
};
</script>
<template>
<div
ref="portalSearchSuggestionsRef"
class="mt-2 overflow-y-auto bg-white dark:bg-n-slate-2 border border-solid border-n-weak rounded-xl shadow-2xl max-h-96 p-2"
>
<div v-if="isLoading" class="px-3 py-6 text-sm text-n-slate-11 text-center">
{{ loadingPlaceholder }}
</div>
<ul v-if="shouldShowResults" class="flex flex-col gap-0.5" role="listbox">
<li
v-for="(article, index) in items"
:id="article.id"
:key="article.id"
class="rounded-md cursor-pointer select-none group transition-colors hover:bg-n-alpha-2"
:class="isSearchItemActive(index)"
role="option"
tabindex="-1"
>
<a
class="flex items-start gap-3 px-3 py-2.5 overflow-hidden"
:href="article.link"
>
<span
class="i-lucide-file-text size-4 mt-0.5 flex-shrink-0 text-n-slate-10 group-hover:text-n-slate-11"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 flex flex-col gap-1">
<span
v-dompurify-html="prepareContent(getPlainText(article.title))"
class="block text-base font-520 text-n-slate-12 truncate"
/>
<span
v-dompurify-html="prepareContent(article.content)"
class="block text-sm text-n-slate-11 line-clamp-1"
/>
</span>
</a>
</li>
</ul>
<div
v-if="showEmptyResults"
class="px-3 py-6 text-sm text-n-slate-11 text-center"
>
{{ emptyPlaceholder }}
</div>
</div>
</template>
@@ -0,0 +1,136 @@
<script>
import { directive as onClickaway } from 'vue3-click-away';
const OPTION_DEFS = [
{ value: 'system', icon: 'i-lucide-monitor', fallback: 'System' },
{ value: 'light', icon: 'i-lucide-sun', fallback: 'Light' },
{ value: 'dark', icon: 'i-lucide-moon', fallback: 'Dark' },
];
const readLabels = () => {
const el = document.querySelector('#sidebar-theme-toggle');
const data = (el && el.dataset) || {};
return {
trigger: data.labelTrigger || 'Appearance',
options: OPTION_DEFS.map(opt => ({
...opt,
label:
data[
`label${opt.value.charAt(0).toUpperCase()}${opt.value.slice(1)}`
] || opt.fallback,
})),
};
};
export default {
directives: { onClickaway },
data() {
const labels = readLabels();
return {
mode: 'system',
systemPrefersDark: false,
open: false,
options: labels.options,
triggerLabel: labels.trigger,
};
},
computed: {
triggerIcon() {
const opt = this.options.find(o => o.value === this.mode);
return opt ? opt.icon : 'i-lucide-monitor';
},
isDarkActive() {
if (this.mode === 'dark') return true;
if (this.mode === 'light') return false;
return this.systemPrefersDark;
},
},
mounted() {
try {
const stored = localStorage.theme;
this.mode = stored === 'dark' || stored === 'light' ? stored : 'system';
} catch (e) {
this.mode = 'system';
}
if (window.matchMedia) {
this.media = window.matchMedia('(prefers-color-scheme: dark)');
this.systemPrefersDark = this.media.matches;
this.media.addEventListener('change', this.onSystemChange);
}
this.applyTheme();
},
unmounted() {
if (this.media)
this.media.removeEventListener('change', this.onSystemChange);
},
methods: {
onSystemChange(e) {
this.systemPrefersDark = e.matches;
if (this.mode === 'system') this.applyTheme();
},
applyTheme() {
const dark = this.isDarkActive;
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(dark ? 'dark' : 'light');
},
select(value) {
this.mode = value;
try {
if (value === 'system') localStorage.removeItem('theme');
else localStorage.theme = value;
} catch (e) {
/* no-op */
}
this.applyTheme();
this.open = false;
},
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
},
},
};
</script>
<template>
<div v-on-clickaway="close" class="relative">
<button
type="button"
class="flex items-center justify-center w-9 h-9 text-n-slate-11 hover:text-n-slate-12 hover:bg-n-alpha-2 rounded-lg transition"
:aria-label="triggerLabel"
:aria-expanded="open"
@click="toggle"
>
<span class="size-4" :class="[triggerIcon]" aria-hidden="true" />
</button>
<div
v-if="open"
class="absolute end-0 top-full mt-2 bg-n-slate-1 border border-solid border-n-weak rounded-lg shadow-lg p-1 min-w-36 z-50 flex flex-col gap-0.5"
>
<button
v-for="opt in options"
:key="opt.value"
type="button"
class="w-full flex items-center justify-between gap-2.5 px-2.5 py-2 text-sm rounded-md transition"
:class="[
mode === opt.value
? 'bg-n-portal-soft text-n-portal'
: 'text-n-slate-11 hover:bg-n-alpha-2 hover:text-n-slate-12',
]"
@click="select(opt.value)"
>
<span class="flex items-center gap-2">
<span class="size-4" :class="[opt.icon]" aria-hidden="true" />
{{ opt.label }}
</span>
<span
v-if="mode === opt.value"
class="i-lucide-check size-3.5"
aria-hidden="true"
/>
</button>
</div>
</div>
</template>
@@ -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-n-portal transition-colors duration-200';
}
return 'border-slate-100 dark:border-slate-800';
},
elementTextStyles(el) {
if (this.isElementActive(el)) {
return 'text-n-portal 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 cursor-pointer"
:class="elementTextStyles(element)"
>
{{ element.title }}
</a>
</p>
</li>
</ol>
</nav>
</div>
</div>
</template>
@@ -0,0 +1,195 @@
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 SidebarThemeToggle from './components/SidebarThemeToggle.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: () => {
['#search-wrap', '#search-wrap-hero'].forEach(selector => {
const mountPoint = document.querySelector(selector);
if (!mountPoint) return;
const size = mountPoint.dataset.size || 'default';
const showKbd = !!mountPoint.dataset.kbd;
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { PublicArticleSearch },
data() {
return { size, showKbd };
},
template: '<PublicArticleSearch :size="size" :show-kbd="showKbd" />',
});
app.use(VueDOMPurifyHTML, domPurifyConfig);
app.directive('on-clickaway', onClickaway);
app.mount(selector);
});
},
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');
}
},
initializeSidebarThemeToggle: () => {
const mountPoint = document.querySelector('#sidebar-theme-toggle');
if (mountPoint) {
// eslint-disable-next-line vue/one-component-per-file
const app = createApp({
components: { SidebarThemeToggle },
template: '<sidebar-theme-toggle />',
});
app.directive('on-clickaway', onClickaway);
app.mount('#sidebar-theme-toggle');
}
},
initializeDetailsClickAway: () => {
document.addEventListener('click', event => {
document
.querySelectorAll('details[data-close-on-clickaway][open]')
.forEach(details => {
if (!details.contains(event.target)) {
details.removeAttribute('open');
}
});
});
},
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();
InitializationHelpers.initializeSidebarThemeToggle();
InitializationHelpers.initializeDetailsClickAway();
}
},
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();
}
},
};
@@ -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();
};
@@ -0,0 +1,90 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PublicArticleSearch from '../components/PublicArticleSearch.vue';
import ArticlesAPI from '../api/article';
vi.mock('../api/article', () => ({
default: {
searchArticles: vi.fn(),
},
}));
describe('PublicArticleSearch', () => {
let originalPortalConfig;
const SearchSuggestionsStub = {
name: 'SearchSuggestions',
template: '<div />',
props: ['searchTerm'],
};
beforeEach(() => {
vi.useFakeTimers();
originalPortalConfig = window.portalConfig;
window.portalConfig = {
portalSlug: 'test-portal',
localeCode: 'en',
searchTranslations: {},
};
});
afterEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
window.portalConfig = originalPortalConfig;
});
const buildWrapper = () =>
shallowMount(PublicArticleSearch, {
global: {
directives: {
onClickaway: () => {},
},
stubs: {
SearchSuggestions: SearchSuggestionsStub,
PublicSearchInput: true,
},
},
});
it('does not fetch or show suggestions for whitespace-only search terms', async () => {
const wrapper = buildWrapper();
wrapper.vm.searchResults = [{ id: 1 }];
wrapper.vm.showSearchBox = true;
wrapper.vm.onUpdateSearchTerm(' ');
await wrapper.vm.$nextTick();
vi.runAllTimers();
await flushPromises();
expect(ArticlesAPI.searchArticles).not.toHaveBeenCalled();
expect(wrapper.vm.searchResults).toEqual([]);
expect(wrapper.vm.shouldShowSearchBox).toBe(false);
expect(wrapper.vm.isLoading).toBe(false);
});
it('trims the search term before requesting articles', async () => {
ArticlesAPI.searchArticles.mockResolvedValue({ data: { payload: [] } });
const wrapper = buildWrapper();
wrapper.vm.onUpdateSearchTerm(' chatwoot ');
vi.runAllTimers();
await flushPromises();
expect(ArticlesAPI.searchArticles).toHaveBeenCalledWith(
'test-portal',
'en',
'chatwoot'
);
});
it('passes the trimmed search term to suggestions for highlighting', async () => {
const wrapper = buildWrapper();
wrapper.vm.onUpdateSearchTerm(' chatwoot ');
await wrapper.vm.$nextTick();
expect(
wrapper.findComponent(SearchSuggestionsStub).props('searchTerm')
).toBe('chatwoot');
});
});
@@ -0,0 +1,44 @@
import { mount } from '@vue/test-utils';
import { describe, it, expect, vi } from 'vitest';
import SearchSuggestions from '../components/SearchSuggestions.vue';
vi.mock('dashboard/composables/useKeyboardNavigableList', () => ({
useKeyboardNavigableList: vi.fn(() => ({})),
}));
vi.mock('shared/composables/useMessageFormatter', () => ({
useMessageFormatter: () => ({
highlightContent: content => content,
getPlainText: content => content,
}),
}));
describe('SearchSuggestions', () => {
it('renders suggestion links from the backend-provided link field', () => {
const wrapper = mount(SearchSuggestions, {
props: {
items: [
{
id: 1,
title: 'Chatwoot Glossary',
content: 'Access Token',
link: '/hc/user-guide/articles/1677141565-chatwoot-glossary',
},
],
isLoading: false,
searchTerm: 'chatwoot',
},
global: {
directives: {
dompurifyHtml: (element, binding) => {
element.innerHTML = binding.value;
},
},
},
});
expect(wrapper.find('a').attributes('href')).toBe(
'/hc/user-guide/articles/1677141565-chatwoot-glossary'
);
});
});
@@ -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');
});
});
@@ -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);
});
});
});