From f9cc702030e878fb78fe6b9c9ac367b67035a59b Mon Sep 17 00:00:00 2001 From: Pranav Date: Wed, 24 Jun 2026 16:12:55 -0700 Subject: [PATCH] fix(help-center): documentation layout on custom domain root, locale (#14850) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help centers using the documentation layout now render correctly in two cases that previously fell back to the wrong output. Opening a portal at its custom-domain root (e.g. docs.example.com/ ) now shows the full documentation home in place instead of the classic layout, and portals whose locale is a region variant that Chatwoot doesn't ship a translation for (e.g. th_TH , fr_ML ) now show their categories in the sidebar on article pages. Closes https://linear.app/chatwoot/issue/CW-7437/portal-layout-is-not-properly-working-in-custom-domain ## How to test 1. Create a portal with the documentation layout and a custom domain (e.g. example.chat.test ), with at least one category containing a published article. 2. Visit the custom-domain root ( http://example.chat.test:3000/ ) → it should show the full documentation home (sidebar, topbar), not the classic layout, with no redirect. 3. Set the portal's locale to a region variant Chatwoot doesn't translate, e.g. th_TH , with categories/articles under that locale. 4. Open an article → the sidebar should list the article's category and sibling articles. Before the fix the sidebar was empty for these locales. --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .circleci/config.yml | 2 +- app/controllers/concerns/portal_home_data.rb | 29 +++++++++++ app/controllers/dashboard_controller.rb | 5 ++ .../public/api/v1/portals/base_controller.rb | 19 +++---- .../public/api/v1/portals_controller.rb | 26 +--------- .../dashboard_custom_domain_spec.rb | 50 +++++++++++++++++++ .../v1/portals/articles_controller_spec.rb | 26 ++++++++++ 7 files changed, 123 insertions(+), 34 deletions(-) create mode 100644 app/controllers/concerns/portal_home_data.rb create mode 100644 spec/controllers/dashboard_custom_domain_spec.rb diff --git a/.circleci/config.yml b/.circleci/config.yml index f764cb611..59702c139 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,7 +144,7 @@ jobs: # Backend tests with parallelization backend-tests: <<: *defaults - parallelism: 20 + parallelism: 18 steps: - checkout - node/install: diff --git a/app/controllers/concerns/portal_home_data.rb b/app/controllers/concerns/portal_home_data.rb new file mode 100644 index 000000000..633071301 --- /dev/null +++ b/app/controllers/concerns/portal_home_data.rb @@ -0,0 +1,29 @@ +module PortalHomeData + extend ActiveSupport::Concern + + private + + def load_home_data + base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) + @visible_categories = @portal.categories + .where(locale: @locale) + .joins(:articles).where(articles: { status: :published }) + .order(position: :asc) + .group('categories.id') + @popular_topics = @visible_categories.first(3) + @featured = base_articles.order_by_views.limit(6) + @category_contributors = build_category_contributors(@visible_categories) + end + + def build_category_contributors(categories) + category_ids = categories.map(&:id) + return {} if category_ids.empty? + + @portal.articles + .published + .where(locale: @locale, category_id: category_ids) + .includes(:author) + .group_by(&:category_id) + .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } + end +end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index b6df015f7..a369830b6 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,5 +1,6 @@ class DashboardController < ActionController::Base include SwitchLocale + include PortalHomeData GLOBAL_CONFIG_KEYS = %w[ LOGO @@ -63,6 +64,10 @@ class DashboardController < ActionController::Base return unless @portal @locale = @portal.default_locale + if @portal.layout == 'documentation' + request.variant = :documentation + load_home_data + end render 'public/api/v1/portals/show', layout: 'portal', portal: @portal and return end diff --git a/app/controllers/public/api/v1/portals/base_controller.rb b/app/controllers/public/api/v1/portals/base_controller.rb index 2991b84d2..323440304 100644 --- a/app/controllers/public/api/v1/portals/base_controller.rb +++ b/app/controllers/public/api/v1/portals/base_controller.rb @@ -39,9 +39,11 @@ class Public::Api::V1::Portals::BaseController < PublicController end def switch_locale_with_portal(&) - @locale = validate_and_get_locale(params[:locale]) + # Keep @locale as the portal's own locale code (e.g. th_TH) for content queries, + # while UI translations fall back to an available I18n locale (e.g. th). + @locale = params[:locale] - I18n.with_locale(@locale, &) + I18n.with_locale(validate_and_get_locale(@locale), &) end def switch_locale_with_article(&) @@ -49,13 +51,12 @@ class Public::Api::V1::Portals::BaseController < PublicController Rails.logger.info "Article: not found for slug: #{params[:article_slug]}" render_404 && return if article.blank? - article_locale = if article.category.present? - article.category.locale - else - article.locale - end - @locale = validate_and_get_locale(article_locale) - I18n.with_locale(@locale, &) + @locale = if article.category.present? + article.category.locale + else + article.locale + end + I18n.with_locale(validate_and_get_locale(@locale), &) end def allow_iframe_requests diff --git a/app/controllers/public/api/v1/portals_controller.rb b/app/controllers/public/api/v1/portals_controller.rb index 57db11aec..4982278d7 100644 --- a/app/controllers/public/api/v1/portals_controller.rb +++ b/app/controllers/public/api/v1/portals_controller.rb @@ -1,4 +1,6 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseController + include PortalHomeData + before_action :ensure_custom_domain_request, only: [:show] before_action :redirect_to_portal_with_locale, only: [:show] before_action :portal @@ -31,28 +33,4 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl portal redirect_to "/hc/#{@portal.slug}/#{@portal.default_locale}" end - - def load_home_data - base_articles = @portal.articles.published.where(locale: @locale).includes(:author, :category) - @visible_categories = @portal.categories - .where(locale: @locale) - .joins(:articles).where(articles: { status: :published }) - .order(position: :asc) - .group('categories.id') - @popular_topics = @visible_categories.first(3) - @featured = base_articles.order_by_views.limit(6) - @category_contributors = build_category_contributors(@visible_categories) - end - - def build_category_contributors(categories) - category_ids = categories.map(&:id) - return {} if category_ids.empty? - - @portal.articles - .published - .where(locale: @locale, category_id: category_ids) - .includes(:author) - .group_by(&:category_id) - .transform_values { |articles| articles.filter_map(&:author).uniq.first(3) } - end end diff --git a/spec/controllers/dashboard_custom_domain_spec.rb b/spec/controllers/dashboard_custom_domain_spec.rb new file mode 100644 index 000000000..4d791defb --- /dev/null +++ b/spec/controllers/dashboard_custom_domain_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +describe 'GET / on a help center custom domain', type: :request do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + + around do |example| + with_modified_env FRONTEND_URL: 'http://www.chatwoot.test' do + example.run + end + end + + context 'when the portal uses the documentation layout' do + let!(:portal) do + create(:portal, account: account, slug: 'doc-portal', custom_domain: 'docs.example.com', + config: { allowed_locales: ['en'], default_locale: 'en', layout: 'documentation' }) + end + let!(:category) do + create(:category, name: 'Getting Started', portal: portal, account_id: account.id, locale: 'en', slug: 'getting-started') + end + + before do + create(:article, category: category, portal: portal, account: account, author: agent, locale: 'en', status: :published) + end + + it 'renders the documentation home in place without redirecting' do + host! portal.custom_domain + get '/' + + expect(response).to have_http_status(:success) + expect(response.body).to include('sidebar-drawer-checkbox') + expect(response.body).to include('Getting Started') + end + end + + context 'when the portal uses the classic layout' do + let!(:portal) do + create(:portal, account: account, slug: 'classic-portal', custom_domain: 'classic.example.com', + config: { allowed_locales: ['en'], default_locale: 'en', layout: 'classic' }) + end + + it 'renders the classic home without the documentation layout' do + host! portal.custom_domain + get '/' + + expect(response).to have_http_status(:success) + expect(response.body).not_to include('sidebar-drawer-checkbox') + end + end +end diff --git a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb index 8bebd3b9d..89d9de6b4 100644 --- a/spec/controllers/public/api/v1/portals/articles_controller_spec.rb +++ b/spec/controllers/public/api/v1/portals/articles_controller_spec.rb @@ -211,4 +211,30 @@ RSpec.describe 'Public Articles API', type: :request do expect(response.headers['Content-Type']).to eq('image/png') end end + + describe 'documentation layout sidebar for a region-variant locale' do + let!(:th_portal) do + create(:portal, slug: 'th-portal', custom_domain: 'th.example.com', + config: { allowed_locales: ['th_TH'], default_locale: 'th_TH', layout: 'documentation' }) + end + let!(:th_category) do + create(:category, name: 'TH Category', portal: th_portal, account_id: account.id, locale: 'th_TH', slug: 'th-cat') + end + let!(:th_article) do + create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id, locale: 'th_TH') + end + + before do + create(:article, category: th_category, portal: th_portal, account_id: account.id, author_id: agent.id, + locale: 'th_TH', title: 'Sibling In Sidebar', status: :published) + end + + it 'lists the category and sibling articles using the full portal locale' do + host! 'th.example.com' + get "/hc/#{th_portal.slug}/articles/#{th_article.slug}" + + expect(response).to have_http_status(:success) + expect(response.body).to include('Sibling In Sidebar') + end + end end