feat: Add draft status for help center locales (#13768)
This adds a draft status for Help Center locales so teams can prepare localized content in the dashboard without exposing those locales in the public portal switcher until they are ready to publish. Fixes: https://github.com/chatwoot/chatwoot/issues/10412 Closes: https://github.com/chatwoot/chatwoot/issues/10412 ## Why Teams need a way to work on locale-specific Help Center content ahead of launch. The public portal should only show ready locales, while the admin dashboard should continue to expose every allowed locale for ongoing article and category work. ## What this change does - Adds `draft_locales` to portal config as a subset of `allowed_locales` - Hides drafted locales from the public portal language switchers while keeping direct locale URLs working - Keeps drafted locales fully visible in the admin dashboard for article and category management - Adds locale actions to move an existing locale to draft, publish a drafted locale, and keep the default locale protected from drafting - Adds a status dropdown when creating a locale so new locales can be created as `Published` or `Draft` - Returns each admin locale with a `draft` flag so the locale UI can reflect the public visibility state ## Validation - Seed a portal with multiple locales, draft one locale, and confirm the public portal switcher hides it while `/hc/:slug/:locale` still loads directly - In the admin dashboard, confirm drafted locales still appear in the locale list and remain selectable for articles and categories - Create a new locale with `Draft` status and confirm it stays out of the public switcher until published - Move an existing locale back and forth between `Published` and `Draft` and confirm the public switcher updates accordingly ## Demo https://github.com/user-attachments/assets/ba22dc26-c2e7-463a-b1f5-adf1fda1f9be --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
@@ -79,7 +79,7 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
def portal_params
|
||||
params.require(:portal).permit(
|
||||
:id, :color, :custom_domain, :header_text, :homepage_link,
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }] }
|
||||
:name, :page_title, :slug, :archived, { config: [:default_locale, { allowed_locales: [] }, { draft_locales: [] }] }
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
<script setup>
|
||||
import LocaleCard from './LocaleCard.vue';
|
||||
const locales = [
|
||||
{ name: 'English', isDefault: true, articleCount: 29, categoryCount: 5 },
|
||||
{ name: 'Spanish', isDefault: false, articleCount: 29, categoryCount: 5 },
|
||||
{
|
||||
name: 'English',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 29,
|
||||
categoryCount: 5,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
@@ -19,6 +33,8 @@ const locales = [
|
||||
<LocaleCard
|
||||
:locale="locale.name"
|
||||
:is-default="locale.isDefault"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articleCount"
|
||||
:category-count="locale.categoryCount"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { LOCALE_MENU_ITEMS } from 'dashboard/helper/portalHelper';
|
||||
import { buildLocaleMenuItems } from 'dashboard/helper/portalHelper';
|
||||
|
||||
import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
@@ -17,6 +17,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isDraft: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
localeCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -37,11 +41,28 @@ const { t } = useI18n();
|
||||
|
||||
const [showDropdownMenu, toggleDropdown] = useToggle();
|
||||
|
||||
const localeLabel = computed(() => `${props.locale} (${props.localeCode})`);
|
||||
|
||||
const localeMenuLabels = computed(() => ({
|
||||
'change-default': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT'
|
||||
),
|
||||
'move-to-draft': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT'
|
||||
),
|
||||
'publish-locale': t(
|
||||
'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE'
|
||||
),
|
||||
delete: t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE'),
|
||||
}));
|
||||
|
||||
const localeMenuItems = computed(() =>
|
||||
LOCALE_MENU_ITEMS.map(item => ({
|
||||
buildLocaleMenuItems({
|
||||
isDefault: props.isDefault,
|
||||
isDraft: props.isDraft,
|
||||
}).map(item => ({
|
||||
...item,
|
||||
label: t(item.label),
|
||||
disabled: props.isDefault,
|
||||
label: localeMenuLabels.value[item.action],
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -56,7 +77,7 @@ const handleAction = ({ action, value }) => {
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex items-center justify-start gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12 line-clamp-1">
|
||||
{{ locale }} ({{ localeCode }})
|
||||
{{ localeLabel }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDefault"
|
||||
@@ -64,6 +85,12 @@ const handleAction = ({ action, value }) => {
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DEFAULT') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="isDraft"
|
||||
class="bg-n-alpha-2 h-6 inline-flex items-center justify-center rounded-md text-xs border-px border-transparent text-n-slate-11 px-2 py-0.5"
|
||||
>
|
||||
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DRAFT') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -86,6 +113,7 @@ const handleAction = ({ action, value }) => {
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="localeMenuItems.length"
|
||||
v-on-clickaway="() => toggleDropdown(false)"
|
||||
class="relative group"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
@@ -24,12 +24,20 @@ const dialogRef = ref(null);
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const selectedLocale = ref('');
|
||||
const localeStatus = ref('published');
|
||||
|
||||
const addedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const draftedLocales = computed(() => {
|
||||
const { allowed_locales: allowedLocales = [] } = props.portal?.config || {};
|
||||
return allowedLocales
|
||||
.filter(locale => locale.draft)
|
||||
.map(locale => locale.code);
|
||||
});
|
||||
|
||||
const locales = computed(() => {
|
||||
return Object.keys(allLocales)
|
||||
.map(key => {
|
||||
@@ -41,17 +49,44 @@ const locales = computed(() => {
|
||||
.filter(locale => !addedLocales.value.includes(locale.value));
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{
|
||||
value: 'published',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.LIVE'),
|
||||
},
|
||||
{
|
||||
value: 'draft',
|
||||
label: t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.OPTIONS.DRAFT'),
|
||||
},
|
||||
]);
|
||||
|
||||
const resetForm = () => {
|
||||
selectedLocale.value = '';
|
||||
localeStatus.value = 'published';
|
||||
};
|
||||
|
||||
watch(localeStatus, value => {
|
||||
if (!value) {
|
||||
localeStatus.value = 'published';
|
||||
}
|
||||
});
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!selectedLocale.value) return;
|
||||
|
||||
isUpdating.value = true;
|
||||
const updatedLocales = [...addedLocales.value, selectedLocale.value];
|
||||
const updatedDraftLocales =
|
||||
localeStatus.value === 'draft'
|
||||
? [...new Set([...draftedLocales.value, selectedLocale.value])]
|
||||
: draftedLocales.value;
|
||||
|
||||
try {
|
||||
await store.dispatch('portals/update', {
|
||||
portalSlug: props.portal?.slug,
|
||||
config: {
|
||||
allowed_locales: updatedLocales,
|
||||
draft_locales: updatedDraftLocales,
|
||||
default_locale: props.portal?.meta?.default_locale,
|
||||
},
|
||||
});
|
||||
@@ -62,7 +97,7 @@ const onCreate = async () => {
|
||||
from: route.name,
|
||||
});
|
||||
|
||||
selectedLocale.value = '';
|
||||
resetForm();
|
||||
dialogRef.value?.close();
|
||||
useAlert(
|
||||
t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.API.SUCCESS_MESSAGE')
|
||||
@@ -87,6 +122,7 @@ defineExpose({ dialogRef });
|
||||
type="edit"
|
||||
:title="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.TITLE')"
|
||||
:description="t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.DESCRIPTION')"
|
||||
@close="resetForm"
|
||||
@confirm="onCreate"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -98,6 +134,16 @@ defineExpose({ dialogRef });
|
||||
"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('HELP_CENTER.LOCALES_PAGE.ADD_LOCALE_DIALOG.STATUS.LABEL') }}
|
||||
</span>
|
||||
<ComboBox
|
||||
v-model="localeStatus"
|
||||
:options="statusOptions"
|
||||
class="[&>div>button:not(.focused)]:!outline-n-slate-5 [&>div>button:not(.focused)]:dark:!outline-n-slate-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -29,6 +29,7 @@ const isLocaleDefault = code => {
|
||||
|
||||
const updatePortalLocales = async ({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey,
|
||||
}) => {
|
||||
@@ -39,6 +40,7 @@ const updatePortalLocales = async ({
|
||||
config: {
|
||||
default_locale: defaultLocale,
|
||||
allowed_locales: newAllowedLocales,
|
||||
draft_locales: newDraftLocales,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -53,8 +55,12 @@ const updatePortalLocales = async ({
|
||||
|
||||
const changeDefaultLocale = ({ localeCode }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const newDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: localeCode,
|
||||
messageKey: 'CHANGE_DEFAULT_LOCALE',
|
||||
});
|
||||
@@ -81,11 +87,15 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
const updatedLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode)
|
||||
.map(locale => locale.code);
|
||||
const updatedDraftLocales = props.locales
|
||||
.filter(locale => locale.code !== localeCode && locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
|
||||
const defaultLocale = props.portal.meta.default_locale;
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales: updatedLocales,
|
||||
newDraftLocales: updatedDraftLocales,
|
||||
defaultLocale,
|
||||
messageKey: 'DELETE_LOCALE',
|
||||
});
|
||||
@@ -98,9 +108,46 @@ const deletePortalLocale = async ({ localeCode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
const updateDraftLocales = async ({ localeCode, shouldDraft, messageKey }) => {
|
||||
const newAllowedLocales = props.locales.map(locale => locale.code);
|
||||
const currentDraftLocales = props.locales
|
||||
.filter(locale => locale.isDraft)
|
||||
.map(locale => locale.code);
|
||||
const newDraftLocales = shouldDraft
|
||||
? [...new Set([...currentDraftLocales, localeCode])]
|
||||
: currentDraftLocales.filter(locale => locale !== localeCode);
|
||||
|
||||
await updatePortalLocales({
|
||||
newAllowedLocales,
|
||||
newDraftLocales,
|
||||
defaultLocale: props.portal.meta.default_locale,
|
||||
messageKey,
|
||||
});
|
||||
};
|
||||
|
||||
const moveLocaleToDraft = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: true,
|
||||
messageKey: 'DRAFT_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const publishLocale = async ({ localeCode }) => {
|
||||
await updateDraftLocales({
|
||||
localeCode,
|
||||
shouldDraft: false,
|
||||
messageKey: 'PUBLISH_LOCALE',
|
||||
});
|
||||
};
|
||||
|
||||
const handleAction = ({ action }, localeCode) => {
|
||||
if (action === 'change-default') {
|
||||
changeDefaultLocale({ localeCode: localeCode });
|
||||
} else if (action === 'move-to-draft') {
|
||||
moveLocaleToDraft({ localeCode: localeCode });
|
||||
} else if (action === 'publish-locale') {
|
||||
publishLocale({ localeCode: localeCode });
|
||||
} else if (action === 'delete') {
|
||||
deletePortalLocale({ localeCode: localeCode });
|
||||
}
|
||||
@@ -114,6 +161,7 @@ const handleAction = ({ action }, localeCode) => {
|
||||
:key="index"
|
||||
:locale="locale.name"
|
||||
:is-default="isLocaleDefault(locale.code)"
|
||||
:is-draft="locale.isDraft"
|
||||
:locale-code="locale.code"
|
||||
:article-count="locale.articlesCount || 0"
|
||||
:category-count="locale.categoriesCount || 0"
|
||||
|
||||
@@ -4,37 +4,49 @@ import LocalesPage from './LocalesPage.vue';
|
||||
const locales = [
|
||||
{
|
||||
name: 'English (en-US)',
|
||||
code: 'en',
|
||||
isDefault: true,
|
||||
isDraft: false,
|
||||
articleCount: 5,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Spanish (es-ES)',
|
||||
code: 'es',
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
articleCount: 20,
|
||||
categoryCount: 10,
|
||||
},
|
||||
{
|
||||
name: 'English (en-UK)',
|
||||
code: 'en_GB',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malay (ms-MY)',
|
||||
code: 'ms',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
{
|
||||
name: 'Malayalam (ml-IN)',
|
||||
code: 'ml',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 10,
|
||||
categoryCount: 5,
|
||||
},
|
||||
{
|
||||
name: 'Hindi (hi-IN)',
|
||||
code: 'hi',
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
articleCount: 15,
|
||||
categoryCount: 7,
|
||||
},
|
||||
|
||||
@@ -133,20 +133,55 @@ export const ARTICLE_TABS_OPTIONS = [
|
||||
},
|
||||
];
|
||||
|
||||
export const LOCALE_MENU_ITEMS = [
|
||||
{
|
||||
export const LOCALE_MENU_ITEMS = {
|
||||
makeDefault: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MAKE_DEFAULT',
|
||||
action: 'change-default',
|
||||
value: 'default',
|
||||
icon: 'i-lucide-star',
|
||||
},
|
||||
{
|
||||
moveToDraft: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.MOVE_TO_DRAFT',
|
||||
action: 'move-to-draft',
|
||||
value: 'draft',
|
||||
icon: 'i-lucide-eye-off',
|
||||
},
|
||||
publishLocale: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.PUBLISH_LOCALE',
|
||||
action: 'publish-locale',
|
||||
value: 'publish',
|
||||
icon: 'i-lucide-eye',
|
||||
},
|
||||
delete: {
|
||||
label: 'HELP_CENTER.LOCALES_PAGE.LOCALE_CARD.DROPDOWN_MENU.DELETE',
|
||||
action: 'delete',
|
||||
value: 'delete',
|
||||
icon: 'i-lucide-trash',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const disableLocaleMenuItems = menuItems =>
|
||||
menuItems.map(item => ({ ...item, disabled: true }));
|
||||
|
||||
export const buildLocaleMenuItems = ({ isDefault, isDraft }) => {
|
||||
if (isDefault) {
|
||||
return disableLocaleMenuItems([
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isDraft) {
|
||||
return [LOCALE_MENU_ITEMS.publishLocale, LOCALE_MENU_ITEMS.delete];
|
||||
}
|
||||
|
||||
return [
|
||||
LOCALE_MENU_ITEMS.makeDefault,
|
||||
LOCALE_MENU_ITEMS.moveToDraft,
|
||||
LOCALE_MENU_ITEMS.delete,
|
||||
];
|
||||
};
|
||||
|
||||
export const ARTICLE_EDITOR_STATUS_OPTIONS = {
|
||||
published: ['archive', 'draft'],
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildPortalArticleURL, buildPortalURL } from '../portalHelper';
|
||||
import {
|
||||
buildLocaleMenuItems,
|
||||
buildPortalArticleURL,
|
||||
buildPortalURL,
|
||||
} from '../portalHelper';
|
||||
|
||||
describe('PortalHelper', () => {
|
||||
describe('buildPortalURL', () => {
|
||||
@@ -68,4 +72,39 @@ describe('PortalHelper', () => {
|
||||
).toEqual('https://app.chatwoot.com/hc/handbook/articles/article-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLocaleMenuItems', () => {
|
||||
it('returns disabled actions for the default locale', () => {
|
||||
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 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('returns publish and delete actions for draft locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: true,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['publish-locale', 'delete']);
|
||||
});
|
||||
|
||||
it('returns default, draft, and delete actions for live locales', () => {
|
||||
expect(
|
||||
buildLocaleMenuItems({
|
||||
isDefault: false,
|
||||
isDraft: false,
|
||||
}).map(({ action }) => action)
|
||||
).toEqual(['change-default', 'move-to-draft', 'delete']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,6 +316,18 @@
|
||||
"SUCCESS_MESSAGE": "Locale removed from portal successfully",
|
||||
"ERROR_MESSAGE": "Unable to remove locale from portal. Try again."
|
||||
}
|
||||
},
|
||||
"DRAFT_LOCALE": {
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale moved to draft successfully",
|
||||
"ERROR_MESSAGE": "Unable to move locale to draft. Try again."
|
||||
}
|
||||
},
|
||||
"PUBLISH_LOCALE": {
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale published successfully",
|
||||
"ERROR_MESSAGE": "Unable to publish locale. Try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
"TABLE": {
|
||||
@@ -644,8 +656,11 @@
|
||||
"ARTICLES_COUNT": "{count} article | {count} articles",
|
||||
"CATEGORIES_COUNT": "{count} category | {count} categories",
|
||||
"DEFAULT": "Default",
|
||||
"DRAFT": "Draft",
|
||||
"DROPDOWN_MENU": {
|
||||
"MAKE_DEFAULT": "Make default",
|
||||
"MOVE_TO_DRAFT": "Move to draft",
|
||||
"PUBLISH_LOCALE": "Publish locale",
|
||||
"DELETE": "Delete"
|
||||
}
|
||||
},
|
||||
@@ -655,6 +670,13 @@
|
||||
"COMBOBOX": {
|
||||
"PLACEHOLDER": "Select locale..."
|
||||
},
|
||||
"STATUS": {
|
||||
"LABEL": "Status",
|
||||
"OPTIONS": {
|
||||
"LIVE": "Published",
|
||||
"DRAFT": "Draft"
|
||||
}
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Locale added successfully",
|
||||
"ERROR_MESSAGE": "Unable to add locale. Try again."
|
||||
|
||||
@@ -22,6 +22,7 @@ const allowedLocales = computed(() => {
|
||||
id: locale?.code,
|
||||
name: allLocales[locale?.code],
|
||||
code: locale?.code,
|
||||
isDraft: locale?.draft || false,
|
||||
articlesCount: locale?.articles_count || 0,
|
||||
categoriesCount: locale?.categories_count || 0,
|
||||
};
|
||||
|
||||
@@ -92,7 +92,7 @@ class Category < ApplicationRecord
|
||||
def allowed_locales
|
||||
return if portal.blank?
|
||||
|
||||
allowed_locales = portal.config['allowed_locales']
|
||||
allowed_locales = portal.allowed_locale_codes
|
||||
|
||||
return true if allowed_locales.include?(locale)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class Portal < ApplicationRecord
|
||||
|
||||
scope :active, -> { where(archived: false) }
|
||||
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale website_token].freeze
|
||||
CONFIG_JSON_KEYS = %w[allowed_locales default_locale draft_locales website_token].freeze
|
||||
|
||||
def file_base_data
|
||||
{
|
||||
@@ -61,7 +61,29 @@ class Portal < ApplicationRecord
|
||||
end
|
||||
|
||||
def default_locale
|
||||
config['default_locale'] || 'en'
|
||||
config_value('default_locale').presence || allowed_locale_codes.first || 'en'
|
||||
end
|
||||
|
||||
def allowed_locale_codes
|
||||
allowed_locale_codes = normalize_locale_codes(config_value('allowed_locales'))
|
||||
return allowed_locale_codes if allowed_locale_codes.present?
|
||||
|
||||
[config_value('default_locale').presence || 'en']
|
||||
end
|
||||
|
||||
def draft_locale_codes
|
||||
allowed_locales = allowed_locale_codes
|
||||
drafted_locales = normalize_locale_codes(drafted_locale_values)
|
||||
|
||||
allowed_locales.select { |locale| drafted_locales.include?(locale) }
|
||||
end
|
||||
|
||||
def public_locale_codes
|
||||
allowed_locale_codes - draft_locale_codes
|
||||
end
|
||||
|
||||
def draft_locale?(locale)
|
||||
draft_locale_codes.include?(locale)
|
||||
end
|
||||
|
||||
def color
|
||||
@@ -75,9 +97,37 @@ class Portal < ApplicationRecord
|
||||
private
|
||||
|
||||
def config_json_format
|
||||
self.config = (config || {}).deep_stringify_keys
|
||||
config['allowed_locales'] = allowed_locale_codes
|
||||
config['default_locale'] = default_locale
|
||||
config['draft_locales'] = draft_locale_codes
|
||||
denied_keys = config.keys - CONFIG_JSON_KEYS
|
||||
errors.add(:cofig, "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)
|
||||
end
|
||||
|
||||
def normalize_locale_codes(locale_codes)
|
||||
Array(locale_codes).filter_map(&:presence).uniq
|
||||
end
|
||||
|
||||
def persisted_config
|
||||
(attribute_in_database('config') || {}).deep_stringify_keys
|
||||
end
|
||||
|
||||
def drafted_locale_values
|
||||
return config_value('draft_locales') if config_has_key?('draft_locales')
|
||||
|
||||
persisted_config['draft_locales']
|
||||
end
|
||||
|
||||
def config_has_key?(key)
|
||||
config.is_a?(Hash) && (config.key?(key) || config.key?(key.to_sym))
|
||||
end
|
||||
|
||||
def config_value(key)
|
||||
return unless config.is_a?(Hash)
|
||||
|
||||
config[key] || config[key.to_sym]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ json.account_id portal.account_id
|
||||
|
||||
json.config do
|
||||
json.allowed_locales do
|
||||
json.array! portal.config['allowed_locales'].each do |locale|
|
||||
json.array! portal.allowed_locale_codes.each do |locale|
|
||||
json.partial! 'api/v1/models/portal_config', formats: [:json], locale: locale, portal: portal
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
json.code locale
|
||||
json.articles_count portal.articles.search({ locale: locale }).size
|
||||
json.categories_count portal.categories.search_by_locale(locale).size
|
||||
json.draft portal.draft_locale?(locale)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
</div>
|
||||
|
||||
<%# Locale switcher section %>
|
||||
<% if @portal.config["allowed_locales"].length > 1 %>
|
||||
<% if @portal.public_locale_codes.length > 1 %>
|
||||
<div id="header-action-button" class="flex items-center stroke-slate-700 dark:stroke-slate-200 text-slate-800 dark:text-slate-100">
|
||||
<div class="flex items-center gap-1 px-1 py-2 cursor-pointer">
|
||||
<%= render partial: 'icons/globe' %>
|
||||
@@ -86,7 +86,10 @@
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="w-24 overflow-hidden text-sm font-medium leading-tight bg-white appearance-none cursor-pointer dark:bg-slate-900 text-ellipsis whitespace-nowrap focus:outline-none focus:shadow-outline locale-switcher"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<% if @portal.draft_locale?(@locale) %>
|
||||
<option selected disabled value="<%= @locale %>"><%= "#{language_name(@locale)} (#{@locale})" %></option>
|
||||
<% end %>
|
||||
<% @portal.public_locale_codes.each do |locale| %>
|
||||
<option <%= locale == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% has_multiple_locales = @portal.config["allowed_locales"].length > 1 %>
|
||||
<% has_multiple_locales = @portal.public_locale_codes.length > 1 %>
|
||||
|
||||
<input type="checkbox" id="mobile-menu-toggle" class="peer/menu hidden" />
|
||||
|
||||
@@ -63,7 +63,10 @@
|
||||
data-portal-slug="<%= @portal.slug %>"
|
||||
class="flex-1 bg-transparent text-lg font-medium cursor-pointer focus:outline-none locale-switcher text-slate-800 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
>
|
||||
<% @portal.config["allowed_locales"].each do |locale| %>
|
||||
<% if @portal.draft_locale?(@locale) %>
|
||||
<option selected disabled value="<%= @locale %>"><%= "#{language_name(@locale)} (#{@locale})" %></option>
|
||||
<% end %>
|
||||
<% @portal.public_locale_codes.each do |locale| %>
|
||||
<option <%= locale == @locale ? 'selected': '' %> value="<%= locale %>"><%= "#{language_name(locale)} (#{locale})" %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
@@ -117,7 +117,7 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
portal_params = {
|
||||
portal: {
|
||||
name: 'updated_test_portal',
|
||||
config: { 'allowed_locales' => %w[en es] }
|
||||
config: { 'allowed_locales' => %w[en es], 'draft_locales' => ['es'], 'default_locale' => 'en' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +130,33 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['name']).to eql(portal_params[:portal][:name])
|
||||
expect(json_response['config']).to eql({ 'allowed_locales' => [{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en' },
|
||||
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es' }] })
|
||||
expect(json_response['config']).to eql(
|
||||
{
|
||||
'allowed_locales' => [
|
||||
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'en', 'draft' => false },
|
||||
{ 'articles_count' => 0, 'categories_count' => 0, 'code' => 'es', 'draft' => true }
|
||||
]
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'preserves drafted locales when draft_locales is omitted' do
|
||||
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/portals/#{portal.slug}",
|
||||
params: {
|
||||
portal: {
|
||||
config: { allowed_locales: %w[en es fr], default_locale: 'en' }
|
||||
}
|
||||
},
|
||||
headers: admin.create_new_auth_token
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
portal.reload
|
||||
expect(portal.draft_locale_codes).to eq(['es'])
|
||||
expect(response.parsed_body.dig('config', 'allowed_locales')).to include(
|
||||
a_hash_including('code' => 'es', 'draft' => true)
|
||||
)
|
||||
end
|
||||
|
||||
it 'archive portal' do
|
||||
|
||||
@@ -56,6 +56,48 @@ RSpec.describe Public::Api::V1::PortalsController, type: :request do
|
||||
expect(response.body).not_to include('<link rel="icon" href=')
|
||||
end
|
||||
end
|
||||
|
||||
it 'hides drafted locales from the public locale switcher' do
|
||||
portal.update!(config: { allowed_locales: %w[en es], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
get "/hc/#{portal.slug}/en"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.body).not_to include('value="es"')
|
||||
expect(response.body).not_to include('locale-switcher')
|
||||
end
|
||||
|
||||
it 'allows direct access to drafted locale pages' do
|
||||
portal.update!(config: { allowed_locales: %w[en es], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
get "/hc/#{portal.slug}/es"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'shows the active drafted locale in the switcher state on direct locale access' do
|
||||
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
get "/hc/#{portal.slug}/es"
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
document = Nokogiri::HTML(response.body)
|
||||
switchers = document.css('select.locale-switcher')
|
||||
|
||||
expect(switchers).not_to be_empty
|
||||
|
||||
switchers.each do |switcher|
|
||||
options = switcher.css('option')
|
||||
|
||||
expect(options.map { |option| option['value'] }).to include('en', 'es', 'fr')
|
||||
expect(
|
||||
options.any? do |option|
|
||||
option['value'] == 'es' && option['selected'].present? && option['disabled'].present?
|
||||
end
|
||||
).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /public/api/v1/portals/{portal_slug}/sitemap' do
|
||||
|
||||
@@ -24,6 +24,7 @@ RSpec.describe Portal do
|
||||
expect(portal.config).to be_present
|
||||
expect(portal.config['allowed_locales']).to eq(['en'])
|
||||
expect(portal.config['default_locale']).to eq('en')
|
||||
expect(portal.config['draft_locales']).to eq([])
|
||||
end
|
||||
|
||||
it 'Does not allow any other config than allowed_locales' do
|
||||
@@ -32,6 +33,29 @@ RSpec.describe Portal do
|
||||
expect(portal.errors.full_messages[0]).to eq('Cofig in portal on some_other_key is not supported.')
|
||||
end
|
||||
|
||||
it 'falls back to no drafted locales for existing portals' do
|
||||
portal.config = { 'allowed_locales' => %w[en es], 'default_locale' => 'en' }
|
||||
|
||||
expect(portal.draft_locale_codes).to eq([])
|
||||
expect(portal.public_locale_codes).to eq(%w[en es])
|
||||
end
|
||||
|
||||
it 'preserves drafted locales when draft_locales is omitted on update' do
|
||||
portal.update!(config: { allowed_locales: %w[en es fr], draft_locales: ['es'], default_locale: 'en' })
|
||||
|
||||
portal.assign_attributes(config: { allowed_locales: %w[en es fr], default_locale: 'en' })
|
||||
portal.valid?
|
||||
|
||||
expect(portal.config['draft_locales']).to eq(['es'])
|
||||
end
|
||||
|
||||
it 'does not allow drafting the default locale' do
|
||||
portal.update(config: { allowed_locales: %w[en es], draft_locales: ['en'], default_locale: 'en' })
|
||||
|
||||
expect(portal).not_to be_valid
|
||||
expect(portal.errors.full_messages).to include('Config default locale cannot be drafted.')
|
||||
end
|
||||
|
||||
it 'converts empty string to nil' do
|
||||
portal.update(custom_domain: '')
|
||||
expect(portal.custom_domain).to be_nil
|
||||
|
||||
Reference in New Issue
Block a user