From bf3b1676dd9e9549b4171bf284ced4ae69c71980 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 12 Jun 2025 00:37:24 +0530 Subject: [PATCH] fix: Broken header in public Help Center portal (#11704) Fixes https://linear.app/chatwoot/issue/CW-4473/broken-header-in-help-center-portal --- app/javascript/portal/portalHelpers.js | 14 +- app/javascript/portal/portalThemeHelper.js | 122 ++++++++++-------- app/javascript/portal/specs/portal.spec.js | 19 ++- .../portal/specs/portalTheme.spec.js | 79 +++++++----- app/views/icons/_close.html.erb | 3 + app/views/icons/_hamburger.html.erb | 3 + app/views/layouts/portal.html.erb | 9 +- .../public/api/v1/portals/_header.html.erb | 38 ++++-- .../public/api/v1/portals/_hero.html.erb | 5 +- .../api/v1/portals/_mobile_menu.html.erb | 83 ++++++++++++ config/locales/en.yml | 1 + 11 files changed, 256 insertions(+), 120 deletions(-) create mode 100644 app/views/icons/_close.html.erb create mode 100644 app/views/icons/_hamburger.html.erb create mode 100644 app/views/public/api/v1/portals/_mobile_menu.html.erb diff --git a/app/javascript/portal/portalHelpers.js b/app/javascript/portal/portalHelpers.js index 93b155915..35e54d154 100644 --- a/app/javascript/portal/portalHelpers.js +++ b/app/javascript/portal/portalHelpers.js @@ -59,17 +59,13 @@ export const openExternalLinksInNewTab = () => { export const InitializationHelpers = { navigateToLocalePage: () => { - const allLocaleSwitcher = document.querySelector('.locale-switcher'); + document.addEventListener('change', e => { + const localeSwitcher = e.target.closest('.locale-switcher'); + if (!localeSwitcher) return; - if (!allLocaleSwitcher) { - return false; - } - - const { portalSlug } = allLocaleSwitcher.dataset; - allLocaleSwitcher.addEventListener('change', event => { - window.location = `/hc/${portalSlug}/${event.target.value}/`; + const { portalSlug } = localeSwitcher.dataset; + window.location.href = `/hc/${encodeURIComponent(portalSlug)}/${encodeURIComponent(localeSwitcher.value)}/`; }); - return false; }, initializeSearch: () => { diff --git a/app/javascript/portal/portalThemeHelper.js b/app/javascript/portal/portalThemeHelper.js index b0c9eaca5..497182a83 100644 --- a/app/javascript/portal/portalThemeHelper.js +++ b/app/javascript/portal/portalThemeHelper.js @@ -1,16 +1,20 @@ 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 - if (theme === 'system') { - const prefersDarkMode = window.matchMedia( - '(prefers-color-scheme: dark)' - ).matches; - theme = prefersDarkMode ? 'dark' : 'light'; - } - + const resolvedTheme = getResolvedTheme(theme); const portalColor = window.portalConfig.portalColor; - const bgColor = theme === 'dark' ? '#151718' : 'white'; + const bgColor = resolvedTheme === 'dark' ? '#151718' : 'white'; const hoverColor = adjustColorForContrast(portalColor, bgColor); // Set hover color for border and text dynamically @@ -36,67 +40,80 @@ export const removeQueryParamsFromUrl = (queryParam = 'theme') => { 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 allElementInButton = - themeToggleButton.querySelectorAll('.theme-button'); - if (!allElementInButton) return; - allElementInButton.forEach(button => { - button.classList.toggle('hidden', button.dataset.theme !== theme); - button.classList.toggle('flex', button.dataset.theme === theme); + 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'); - const prefersDarkMode = window.matchMedia( - '(prefers-color-scheme: dark)' - ).matches; - // remove this so that the system theme is used - - document.documentElement.classList.remove('dark', 'light'); - document.documentElement.classList.add(prefersDarkMode ? 'dark' : 'light'); } else { localStorage.theme = theme; - - document.documentElement.classList.remove('dark', 'light'); - document.documentElement.classList.add(theme); } + const resolvedTheme = getResolvedTheme(theme); + document.documentElement.classList.remove('dark', 'light'); + document.documentElement.classList.add(resolvedTheme); + setPortalHoverColor(theme); updateThemeInHeader(theme); removeQueryParamsFromUrl(); -}; - -export const initializeThemeSwitchButtons = () => { - const appearanceDropdown = document.getElementById('appearance-dropdown'); - appearanceDropdown.dataset.currentTheme = localStorage.theme || 'system'; - - appearanceDropdown.addEventListener('click', event => { - const target = event.target.closest('button[data-theme]'); - - if (target) { - const { theme } = target.dataset; - // setting this data property will automatically toggle the checkmark using CSS - appearanceDropdown.dataset.currentTheme = theme; - switchTheme(theme); - // wait for a bit before hiding the dropdown - appearanceDropdown.style.display = 'none'; - } + // Update both dropdown data attributes + document.querySelectorAll('.appearance-menu').forEach(menu => { + menu.dataset.currentTheme = theme; }); }; -export const initializeToggleButton = () => { - const themeToggleButton = document.getElementById('toggle-appearance'); +export const initializeThemeHandlers = () => { + const toggle = document.getElementById('toggle-appearance'); + const dropdown = document.getElementById('appearance-dropdown'); + if (!toggle || !dropdown) return; - themeToggleButton?.addEventListener('click', () => { - const appearanceDropdown = document.getElementById('appearance-dropdown'); + // Toggle appearance dropdown + toggle.addEventListener('click', e => { + e.stopPropagation(); + dropdown.dataset.dropdownOpen = String( + dropdown.dataset.dropdownOpen !== 'true' + ); + }); - const isCurrentlyHidden = appearanceDropdown.style.display === 'none'; - // Toggle the appearanceDropdown - appearanceDropdown.style.display = isCurrentlyHidden ? 'flex' : 'none'; + 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'; + } }); }; @@ -114,13 +131,12 @@ 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 - setPortalHoverColor(localStorage.theme || 'system'); + switchTheme(localStorage.theme || 'system'); + window.updateThemeInHeader = updateThemeInHeader; - updateThemeInHeader(localStorage.theme || 'system'); // add the event listeners for the dropdown toggle and theme buttons - initializeToggleButton(); - initializeThemeSwitchButtons(); + initializeThemeHandlers(); // add the media query listener to update the theme when the system theme changes initializeMediaQueryListener(); diff --git a/app/javascript/portal/specs/portal.spec.js b/app/javascript/portal/specs/portal.spec.js index 5205c5d45..c3577ec3b 100644 --- a/app/javascript/portal/specs/portal.spec.js +++ b/app/javascript/portal/specs/portal.spec.js @@ -29,22 +29,21 @@ describe('InitializationHelpers.navigateToLocalePage', () => { delete global.window; }); - it('should return false if .locale-switcher is not found', () => { + it('sets up document event listener regardless of locale-switcher existence', () => { document.querySelector('.locale-switcher').remove(); - const result = InitializationHelpers.navigateToLocalePage(); - expect(result).toBe(false); + const documentSpy = vi.spyOn(document, 'addEventListener'); + InitializationHelpers.navigateToLocalePage(); + expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function)); + documentSpy.mockRestore(); }); - it('should add change event listener to .locale-switcher', () => { - const localeSwitcher = document.querySelector('.locale-switcher'); - const addEventListenerSpy = vi.spyOn(localeSwitcher, 'addEventListener'); + it('adds document-level event listener to handle locale switching', () => { + const documentSpy = vi.spyOn(document, 'addEventListener'); InitializationHelpers.navigateToLocalePage(); - expect(addEventListenerSpy).toHaveBeenCalledWith( - 'change', - expect.any(Function) - ); + expect(documentSpy).toHaveBeenCalledWith('change', expect.any(Function)); + documentSpy.mockRestore(); }); }); diff --git a/app/javascript/portal/specs/portalTheme.spec.js b/app/javascript/portal/specs/portalTheme.spec.js index e87999242..d936996b5 100644 --- a/app/javascript/portal/specs/portalTheme.spec.js +++ b/app/javascript/portal/specs/portalTheme.spec.js @@ -3,8 +3,7 @@ import { removeQueryParamsFromUrl, updateThemeInHeader, switchTheme, - initializeThemeSwitchButtons, - initializeToggleButton, + initializeThemeHandlers, initializeMediaQueryListener, initializeTheme, } from '../portalThemeHelper.js'; @@ -21,6 +20,7 @@ describe('portalThemeHelper', () => { appearanceDropdown = document.createElement('div'); appearanceDropdown.id = 'appearance-dropdown'; + appearanceDropdown.classList.add('appearance-menu'); document.body.appendChild(appearanceDropdown); window.matchMedia = vi.fn().mockImplementation(query => ({ @@ -142,7 +142,7 @@ describe('portalThemeHelper', () => { }); }); - describe('#initializeThemeSwitchButtons', () => { + describe('#initializeThemeHandlers', () => { beforeEach(() => { appearanceDropdown.innerHTML = ` @@ -153,43 +153,58 @@ describe('portalThemeHelper', () => { it('does nothing if the appearance dropdown is not found', () => { appearanceDropdown.remove(); - expect(appearanceDropdown.dataset.currentTheme).toBeUndefined(); - }); - it('should set current theme to system if no theme in localStorage', () => { - localStorage.removeItem('theme'); - initializeThemeSwitchButtons(); - expect(appearanceDropdown.dataset.currentTheme).toBe('system'); + expect(() => initializeThemeHandlers()).not.toThrow(); }); - it('sets the current theme to the light theme', () => { - localStorage.theme = 'light'; - appearanceDropdown.dataset.currentTheme = 'light'; - initializeThemeSwitchButtons(); + 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('sets the current theme to the dark theme', () => { - localStorage.theme = 'dark'; - appearanceDropdown.dataset.currentTheme = 'dark'; - initializeThemeSwitchButtons(); - expect(appearanceDropdown.dataset.currentTheme).toBe('dark'); - }); - }); + it('should toggle dropdown visibility on toggle button click', () => { + initializeThemeHandlers(); - describe('#initializeToggleButton', () => { - it('does nothing if the theme toggle button is not found', () => { - themeToggleButton.remove(); - initializeToggleButton(); - expect(appearanceDropdown.style.display).toBe(''); + // 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('toggles the appearance dropdown show/hide', () => { - themeToggleButton.click(); - appearanceDropdown.style.display = 'flex'; - expect(appearanceDropdown.style.display).toBe('flex'); - themeToggleButton.click(); - appearanceDropdown.style.display = 'none'; - expect(appearanceDropdown.style.display).toBe('none'); + 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'); }); }); diff --git a/app/views/icons/_close.html.erb b/app/views/icons/_close.html.erb new file mode 100644 index 000000000..43be0c5aa --- /dev/null +++ b/app/views/icons/_close.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/icons/_hamburger.html.erb b/app/views/icons/_hamburger.html.erb new file mode 100644 index 000000000..e9dbed962 --- /dev/null +++ b/app/views/icons/_hamburger.html.erb @@ -0,0 +1,3 @@ + + + diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 6ed0c8201..72b086d4b 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -24,12 +24,9 @@ By default, it renders: <%= vite_client_tag %> <%= vite_javascript_tag 'portal' %> diff --git a/app/views/public/api/v1/portals/_header.html.erb b/app/views/public/api/v1/portals/_header.html.erb index 1fe097688..694cd240f 100644 --- a/app/views/public/api/v1/portals/_header.html.erb +++ b/app/views/public/api/v1/portals/_header.html.erb @@ -1,11 +1,11 @@
-
\ No newline at end of file + + + diff --git a/app/views/public/api/v1/portals/_hero.html.erb b/app/views/public/api/v1/portals/_hero.html.erb index a245b667f..7c5ba1f2a 100644 --- a/app/views/public/api/v1/portals/_hero.html.erb +++ b/app/views/public/api/v1/portals/_hero.html.erb @@ -1,11 +1,12 @@ <% if !@is_plain_layout_enabled %>
-
+
+ <%= @portal.name %>

<%= portal.header_text %>

-

<%= I18n.t('public_portal.hero.sub_title') %>

+

<%= I18n.t('public_portal.hero.sub_title') %>

diff --git a/app/views/public/api/v1/portals/_mobile_menu.html.erb b/app/views/public/api/v1/portals/_mobile_menu.html.erb new file mode 100644 index 000000000..d4c88da32 --- /dev/null +++ b/app/views/public/api/v1/portals/_mobile_menu.html.erb @@ -0,0 +1,83 @@ + + + + + + diff --git a/config/locales/en.yml b/config/locales/en.yml index 77f2af9b4..59d7b8850 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -288,6 +288,7 @@ en: made_with: Made with header: go_to_homepage: Website + visit_website: Visit website appearance: system: System light: Light