feat(help-center): support per-locale portal title, name & header (#14642)

Portals can now override their name, page title and header text per
locale, with a fallback chain of locale override → default locale → base
value. Overrides are stored under portal.config.locale_translations and
validated with a JSON schema.

Editing is exposed as a "Localize content" action in each non-default
locale's menu on the Locale page, and all public-facing portal views
(classic + documentation layouts) render the localized values. The live
chat widget is also loaded in the active portal locale.


<img width="494" height="395" alt="Screenshot 2026-06-03 at 2 48 59 PM"
src="https://github.com/user-attachments/assets/e55a06e8-f20f-4d8a-9352-92fde28a9a19"
/>



https://github.com/user-attachments/assets/f5affbd2-7ad4-415a-b376-57c759fc2aaa

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-06-04 06:34:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent cd9192f7d1
commit 64c5aeebee
27 changed files with 347 additions and 50 deletions
@@ -80,10 +80,15 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
:id, :color, :custom_domain, :header_text, :homepage_link,
:name, :page_title, :slug, :archived,
{ config: [:default_locale, :layout, { allowed_locales: [] }, { draft_locales: [] },
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] }] }
{ social_profiles: %i[facebook x instagram linkedin youtube tiktok github whatsapp] },
{ locale_translations: locale_translation_keys.index_with { %i[name page_title header_text] } }] }
)
end
def locale_translation_keys
params.dig(:portal, :config, :locale_translations)&.keys || []
end
def live_chat_widget_params
permitted_params = params.permit(:inbox_id)
return {} unless permitted_params.key?(:inbox_id)
@@ -9,7 +9,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
@og_image_url = helpers.set_og_image_url('', @portal.localized_value('header_text', @locale))
end
def sitemap
@@ -53,6 +53,9 @@ const localeMenuLabels = computed(() => ({
'publish-locale': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
),
'customize-content': t(
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT'
),
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
}));
@@ -128,7 +131,7 @@ const handleAction = ({ action, value }) => {
<DropdownMenu
v-if="showDropdownMenu"
:menu-items="localeMenuItems"
class="ltr:right-0 rtl:left-0 mt-1 xl:ltr:left-0 xl:rtl:right-0 top-full z-60 min-w-[150px]"
class="ltr:right-0 rtl:left-0 mt-1 top-full z-60 min-w-[150px]"
@action="handleAction"
/>
</div>
@@ -0,0 +1,98 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
portal: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const activeLocale = ref('');
const name = ref('');
const pageTitle = ref('');
const headerText = ref('');
const localeTranslations = computed(
() => props.portal?.config?.locale_translations || {}
);
const openForLocale = localeCode => {
const existing = localeTranslations.value[localeCode] || {};
activeLocale.value = localeCode;
name.value = existing.name || '';
pageTitle.value = existing.page_title || '';
headerText.value = existing.header_text || '';
dialogRef.value?.open();
};
const onConfirm = async () => {
const translations = { ...localeTranslations.value };
const fields = {};
if (name.value.trim()) fields.name = name.value.trim();
if (pageTitle.value.trim()) fields.page_title = pageTitle.value.trim();
if (headerText.value.trim()) fields.header_text = headerText.value.trim();
if (Object.keys(fields).length) {
translations[activeLocale.value] = fields;
} else {
delete translations[activeLocale.value];
}
try {
await store.dispatch('portals/update', {
portalSlug: props.portal?.slug,
config: { locale_translations: translations },
});
dialogRef.value?.close();
useAlert(t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(
error?.message ||
t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.API.ERROR_MESSAGE')
);
}
};
defineExpose({ openForLocale });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.TITLE')"
:description="t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.DESCRIPTION')"
@confirm="onConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.NAME.LABEL') }}
</label>
<Input v-model="name" :placeholder="portal.name" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.PAGE_TITLE.LABEL') }}
</label>
<Input v-model="pageTitle" :placeholder="portal.page_title" />
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('HELP_CENTER.LOCALES_PAGE.CONTENT_DIALOG.HEADER_TEXT.LABEL') }}
</label>
<Input v-model="headerText" :placeholder="portal.header_text" />
</div>
</div>
</Dialog>
</template>
@@ -1,5 +1,7 @@
<script setup>
import { ref } from 'vue';
import LocaleCard from 'dashboard/components-next/HelpCenter/LocaleCard/LocaleCard.vue';
import LocaleContentDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleContentDialog.vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -23,6 +25,8 @@ const { t } = useI18n();
const route = useRoute();
const { uiSettings, updateUISettings } = useUISettings();
const contentDialogRef = ref(null);
const isLocaleDefault = code => {
return props.portal?.meta?.default_locale === code;
};
@@ -148,6 +152,8 @@ const handleAction = ({ action }, localeCode) => {
moveLocaleToDraft({ localeCode: localeCode });
} else if (action === 'publish-locale') {
publishLocale({ localeCode: localeCode });
} else if (action === 'customize-content') {
contentDialogRef.value.openForLocale(localeCode);
} else if (action === 'delete') {
deletePortalLocale({ localeCode: localeCode });
}
@@ -167,5 +173,6 @@ const handleAction = ({ action }, localeCode) => {
:category-count="locale.categoriesCount || 0"
@action="handleAction($event, locale.code)"
/>
<LocaleContentDialog ref="contentDialogRef" :portal="portal" />
</ul>
</template>
@@ -159,6 +159,13 @@ export const LOCALE_MENU_ITEMS = {
value: 'publish',
icon: 'i-lucide-eye',
},
customizeContent: {
label:
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.CUSTOMIZE_CONTENT',
action: 'customize-content',
value: 'customize-content',
icon: 'i-lucide-pencil',
},
delete: {
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
action: 'delete',
@@ -172,20 +179,28 @@ const disableLocaleMenuItems = menuItems =>
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
if (isDefault) {
return disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.delete,
]);
return [
...disableLocaleMenuItems([
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
]),
LOCALE_MENU_ITEMS.customizeContent,
...disableLocaleMenuItems([LOCALE_MENU_ITEMS.delete]),
];
}
if (isDraft) {
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
return [
LOCALE_MENU_ITEMS.publishLocale,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
}
return [
LOCALE_MENU_ITEMS.makeDefault,
LOCALE_MENU_ITEMS.moveToDraft,
LOCALE_MENU_ITEMS.customizeContent,
LOCALE_MENU_ITEMS.delete,
];
};
@@ -74,37 +74,40 @@ describe('PortalHelper', () => {
});
describe('buildLocaleMenuItems', () => {
it('returns disabled actions for the default locale', () => {
it('disables other actions but keeps customize enabled for the default locale', () => {
const items = buildLocaleMenuItems({ isDefault: true, isDraft: false });
const customize = items.find(item => item.action === 'customize-content');
expect(customize).toBeTruthy();
expect(customize.disabled).toBeFalsy();
expect(
buildLocaleMenuItems({
isDefault: true,
isDraft: false,
})
).toEqual(
expect.arrayContaining([
expect.objectContaining({ action: 'change-default', disabled: true }),
expect.objectContaining({ action: 'move-to-draft', disabled: true }),
expect.objectContaining({ action: 'delete', disabled: true }),
])
);
items
.filter(item => item.action !== 'customize-content')
.every(item => item.disabled)
).toBe(true);
});
it('returns publish and delete actions for draft locales', () => {
it('returns publish, customize, and delete actions for draft locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: true,
}).map(({ action }) => action)
).toEqual(['publish-locale', 'delete']);
).toEqual(['publish-locale', 'customize-content', 'delete']);
});
it('returns default, draft, and delete actions for live locales', () => {
it('returns default, draft, customize, and delete actions for live locales', () => {
expect(
buildLocaleMenuItems({
isDefault: false,
isDraft: false,
}).map(({ action }) => action)
).toEqual(['change-default', 'move-to-draft', 'delete']);
).toEqual([
'change-default',
'move-to-draft',
'customize-content',
'delete',
]);
});
});
});
@@ -700,9 +700,27 @@
"MAKE_DEFAULT": "Make default",
"MOVE_TO_DRAFT": "Move to draft",
"PUBLISH_LOCALE": "Publish locale",
"CUSTOMIZE_CONTENT": "Localize content",
"DELETE": "Delete"
}
},
"CONTENT_DIALOG": {
"TITLE": "Localize content",
"DESCRIPTION": "Set values specific to this locale. Anything left blank falls back to the default locale.",
"NAME": {
"LABEL": "Name"
},
"PAGE_TITLE": {
"LABEL": "Page title"
},
"HEADER_TEXT": {
"LABEL": "Header text"
},
"API": {
"SUCCESS_MESSAGE": "Locale content updated successfully",
"ERROR_MESSAGE": "Unable to update locale content. Try again."
}
},
"ADD_LOCALE_DIALOG": {
"TITLE": "Add a new locale",
"DESCRIPTION": "Select the language in which this article will be written. This will be added to your list of translations, and you can add more later.",
@@ -0,0 +1,35 @@
module PortalConfigSchema
extend ActiveSupport::Concern
# Per-locale overrides for portal level fields. Any locale present in
# `allowed_locales` may carry its own `name`, `page_title` and `header_text`.
# Missing values fall back to the default locale and finally to the base column.
LOCALE_TRANSLATION_SCHEMA = {
'type' => 'object',
'properties' => {
'name' => { 'type' => %w[string null] },
'page_title' => { 'type' => %w[string null] },
'header_text' => { 'type' => %w[string null] }
},
'additionalProperties' => false
}.freeze
CONFIG_PARAMS_SCHEMA = {
'type' => 'object',
'properties' => {
'allowed_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
'default_locale' => { 'type' => %w[string null] },
'draft_locales' => { 'type' => %w[array null], 'items' => { 'type' => 'string' } },
'layout' => { 'type' => %w[string null], 'enum' => ['classic', 'documentation', nil] },
# TODO: unused reserved key; remove with a migration that scrubs it from existing portals' config
'website_token' => { 'type' => %w[string null] },
'social_profiles' => { 'type' => %w[object null] },
'locale_translations' => {
'type' => %w[object null],
'additionalProperties' => LOCALE_TRANSLATION_SCHEMA
}
},
'required' => [],
'additionalProperties' => true
}.to_json.freeze
end
+24 -5
View File
@@ -26,6 +26,7 @@
#
class Portal < ApplicationRecord
include Rails.application.routes.url_helpers
include PortalConfigSchema
DEFAULT_COLOR = '#1f93ff'.freeze
@@ -43,11 +44,16 @@ class Portal < ApplicationRecord
validates :slug, presence: true, uniqueness: true
validates :custom_domain, uniqueness: true, allow_nil: true
validates :color, format: { with: /\A#(?:\h{3}|\h{6})\z/ }, allow_blank: true
validate :config_json_format
before_validation :normalize_config
validate :validate_config
validates_with JsonSchemaValidator,
schema: PortalConfigSchema::CONFIG_PARAMS_SCHEMA,
attribute_resolver: ->(record) { record.config }
scope :active, -> { where(archived: false) }
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout].freeze
# TODO: 'website_token' is an unused reserved key; remove with a migration that scrubs it from existing portals' config
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token social_profiles layout locale_translations].freeze
def file_base_data
{
@@ -91,8 +97,18 @@ class Portal < ApplicationRecord
self[:color].presence || DEFAULT_COLOR
end
def display_title
page_title.presence || name
def display_title(locale = default_locale)
localized_value('page_title', locale).presence || localized_value('name', locale)
end
# Resolves a portal level field for a locale, falling back to the default
# locale's value (its override or the base column) when the locale has no
# override of its own.
def localized_value(field, locale = default_locale)
translations = config_value('locale_translations') || {}
translations.dig(locale.to_s, field).presence ||
translations.dig(default_locale, field).presence ||
self[field]
end
def layout
@@ -105,11 +121,14 @@ class Portal < ApplicationRecord
private
def config_json_format
def normalize_config
self.config = persisted_config.merge((config || {}).deep_stringify_keys)
config['allowed_locales'] = allowed_locale_codes
config['default_locale'] = default_locale
config['draft_locales'] = draft_locale_codes
end
def validate_config
denied_keys = config.keys - CONFIG_JSON_KEYS
errors.add(:config, "in portal on #{denied_keys.join(',')} is not supported.") if denied_keys.any?
errors.add(:config, 'default locale cannot be drafted.') if draft_locale?(default_locale)
@@ -18,6 +18,7 @@ json.config do
json.default_locale portal.default_locale
json.layout portal.layout
json.social_profiles portal.social_profiles
json.locale_translations portal.config['locale_translations'] || {}
end
if portal.channel_web_widget
+1 -1
View File
@@ -16,7 +16,7 @@
<% if content_for?(:head) %>
<%= yield(:head) %>
<% else %>
<title><%= @portal.display_title %></title>
<title><%= @portal.display_title(@locale) %></title>
<% end %>
<% if @portal.logo.present? %>
@@ -89,5 +89,9 @@ html.light {
};
</script>
<% if @portal.channel_web_widget.present? && !@is_plain_layout_enabled %>
<script>
window.chatwootSettings = window.chatwootSettings || {};
window.chatwootSettings.locale = '<%= @locale %>';
</script>
<%= @portal.channel_web_widget.web_widget_script.html_safe %>
<% end %>
@@ -5,7 +5,7 @@
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden lg:block' : 'hidden sm:block' %>"><%= @portal.localized_value('name', @locale) %></span>
</a>
</div>
@@ -106,7 +106,7 @@
<% if @portal.logo.present? %>
<img src="<%= url_for(@portal.logo) %>" class="w-auto h-10 ltr:mr-2 rtl:ml-2" draggable="false" />
<% end %>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.name %></span>
<span class="text-lg font-semibold text-slate-900 truncate dark:text-white <%= @portal.logo.present? ? 'hidden' : 'sm:hidden block' %>"><%= @portal.localized_value('name', @locale) %></span>
</a>
<!-- Mobile Menu Component -->
@@ -1,7 +1,7 @@
<% if !@is_plain_layout_enabled %>
<% content_for :head do %>
<title><%= @portal.display_title %></title>
<meta name="title" content="<%= @portal.display_title %>">
<title><%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @portal.display_title(@locale) %>">
<% if @og_image_url.present? %>
<meta name="twitter:card" content="summary_large_image">
@@ -13,9 +13,9 @@
<section id="portal-bg" class="w-full bg-white dark:bg-slate-900 shadow-inner">
<div id="portal-bg-gradient" class="pt-8 pb-8 md:pt-14 md:pb-6 min-h-[240px] md:min-h-[260px]">
<div class="mx-auto max-w-5xl px-4 md:px-8 flex flex-col items-start">
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.name %></span>
<span class="text-sm leading-[24px] font-semibold text-slate-600 dark:text-slate-300 mb-1 <%= @portal.logo.present? ? 'block lg:hidden' : 'hidden' %>"><%= @portal.localized_value('name', @locale) %></span>
<h1 class="text-2xl md:text-4xl text-slate-900 dark:text-white font-semibold leading-normal">
<%= portal.header_text %>
<%= portal.localized_value('header_text', @locale) %>
</h1>
<p class="text-slate-600 dark:text-slate-200 text-start text-lg leading-normal pt-2 pb-4"><%= I18n.t('public_portal.hero.sub_title') %></p>
<div id="search-wrap" class="w-full relative z-30"></div>
@@ -6,7 +6,7 @@
class="leading-8 text-slate-800 hover:underline"
href="<%= generate_home_link(@portal.slug, @category.present? ? @category.slug : '', @theme_from_params, @is_plain_layout_enabled) %>"
>
<%= @portal.name %> <%= I18n.t('public_portal.common.home') %>
<%= @portal.localized_value('name', @locale) %> <%= I18n.t('public_portal.common.home') %>
</a>
<span>/</span>
<span>/</span>
@@ -1,5 +1,5 @@
<% content_for :head do %>
<title><%= @article.title %> | <%= @portal.display_title %></title>
<title><%= @article.title %> | <%= @portal.display_title(@locale) %></title>
<% if @article.meta["title"].present? %>
<meta name="title" content="<%= @article.meta["title"] %>">
<meta property="og:title" content="<%= @article.meta["title"] %>">
@@ -1,7 +1,7 @@
<section class="bg-slate-50 dark:bg-slate-800 py-16 flex flex-col items-center justify-center">
<div class="mx-auto max-w-2xl">
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.header_text %></h1>
<h1 class="text-4xl text-slate-900 dark:text-white font-semibold leading-relaxed text-center"><%= portal.localized_value('header_text', @locale) %></h1>
<p class="text-slate-700 dark:text-slate-100 py-2 text-center"><%= I18n.t('public_portal.hero.sub_title') %></p>
</div>
</section>
@@ -1,6 +1,6 @@
<% content_for :head do %>
<title><%= @category.name %> | <%= @portal.display_title %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title %>">
<title><%= @category.name %> | <%= @portal.display_title(@locale) %></title>
<meta name="title" content="<%= @category.name %> | <%= @portal.display_title(@locale) %>">
<% if @category.description.present? %>
<meta name="description" content="<%= @category.description %>">
<meta property="og:description" content="<%= @category.description %>">
@@ -5,7 +5,7 @@
<div class="relative w-full px-6 md:px-10 py-12 md:py-16">
<div class="max-w-2xl">
<h1 class="text-4xl md:text-5xl leading-tight font-620 tracking-tight text-n-slate-12 text-balance">
<%= portal.header_text.presence || 'How can we help?' %>
<%= portal.localized_value('header_text', @locale).presence || 'How can we help?' %>
</h1>
<p class="mt-4 text-lg leading-normal text-n-slate-11 text-pretty">
<%= I18n.t('public_portal.hero.sub_title') %>
@@ -11,7 +11,7 @@
<% if portal.logo.present? %>
<img src="<%= url_for(portal.logo) %>" class="w-8 h-8 rounded-full" draggable="false" />
<% end %>
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.name %></span>
<span class="text-base font-semibold tracking-tight text-n-slate-12 truncate"><%= portal.localized_value('name', locale) %></span>
<span class="hidden sm:inline-block w-px h-5 bg-n-weak mx-1"></span>
<span class="hidden sm:inline-block text-sm font-medium text-n-slate-11 truncate"><%= I18n.t('public_portal.sidebar.help_center') %></span>
</a>
@@ -1,4 +1,4 @@
<title><%= article.title %> | <%= portal.display_title %></title>
<title><%= article.title %> | <%= portal.display_title(article.locale) %></title>
<% if article.meta["title"].present? %>
<meta name="title" content="<%= article.meta["title"] %>">
<meta property="og:title" content="<%= article.meta["title"] %>">
@@ -1,5 +1,5 @@
<title><%= category.name %> | <%= portal.display_title %></title>
<meta name="title" content="<%= category.name %> | <%= portal.display_title %>">
<title><%= category.name %> | <%= portal.display_title(category.locale) %></title>
<meta name="title" content="<%= category.name %> | <%= portal.display_title(category.locale) %>">
<% if category.description.present? %>
<meta name="description" content="<%= category.description %>">
<meta property="og:description" content="<%= category.description %>">
@@ -1,5 +1,5 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
<% end %>
<div class="px-6 md:px-10 py-8 md:py-10">
@@ -1,5 +1,5 @@
<% content_for :head do %>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.name %></title>
<title><%= I18n.t('public_portal.search.results_for', query: @query) %> | <%= @portal.localized_value('name', @locale) %></title>
<% end %>
<% search_input_class = 'w-full px-4 py-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 placeholder-slate-500 dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' %>
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'layout' => 'classic',
'social_profiles' => {}
'social_profiles' => {},
'locale_translations' => {}
}
)
end
+88
View File
@@ -60,6 +60,94 @@ RSpec.describe Portal do
portal.update(custom_domain: '')
expect(portal.custom_domain).to be_nil
end
context 'with locale_translations' do
it 'allows valid locale translations' do
portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'name' => 'Centro', 'page_title' => 'Título', 'header_text' => 'Hola' } } })
expect(portal).to be_valid
end
it 'rejects unknown fields within a locale translation' do
portal.update(config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'tagline' => 'nope' } } })
expect(portal).not_to be_valid
end
it 'retains a locale override after it becomes the default so it can still be edited' do
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'name' => 'Centro' } } })
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
expect(portal.config['locale_translations']).to eq({ 'es' => { 'name' => 'Centro' } })
end
end
end
end
describe '#localized_value' do
let!(:account) { create(:account) }
let!(:portal) do
create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
end
it 'returns the override for the requested locale' do
expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
end
it 'falls back to the base column when the locale has no override for the field' do
expect(portal.localized_value('page_title', 'es')).to eq('Help Center | Acme')
end
it 'falls back to the base column when the locale has no overrides at all' do
expect(portal.localized_value('name', 'fr')).to eq('Help Center')
end
it 'keeps serving the override for a locale that has become the default' do
portal.update!(config: { allowed_locales: %w[en es], default_locale: 'es' })
expect(portal.localized_value('name', 'es')).to eq('Centro de ayuda')
end
it "inherits the default locale's override for a locale without its own" do
portal.update!(config: { allowed_locales: %w[en es fr], default_locale: 'es' })
expect(portal.localized_value('name', 'fr')).to eq('Centro de ayuda')
end
it 'uses the default locale when no locale is given' do
expect(portal.localized_value('name')).to eq('Help Center')
end
end
describe '#display_title' do
let!(:account) { create(:account) }
it 'prefers the localized page_title' do
portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme',
config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'page_title' => 'Centro | Acme' } } })
expect(portal.display_title('es')).to eq('Centro | Acme')
end
it 'falls back to the localized name when no page_title is set' do
portal = create(:portal, account_id: account.id, name: 'Help Center',
config: { allowed_locales: %w[en es], default_locale: 'en',
locale_translations: { 'es' => { 'name' => 'Centro de ayuda' } } })
expect(portal.display_title('es')).to eq('Centro de ayuda')
end
it 'uses the base values for the default locale' do
portal = create(:portal, account_id: account.id, name: 'Help Center', page_title: 'Help Center | Acme')
expect(portal.display_title).to eq('Help Center | Acme')
end
end
end