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:
@@ -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 > <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