From b01e47feecd30f0547052c9ad9e0ab89b8844e5d Mon Sep 17 00:00:00 2001 From: agagancarczyk <4890675+agagancarczyk@users.noreply.github.com> Date: Mon, 13 May 2024 14:50:00 +0100 Subject: [PATCH] Added localization for User Profile attribute groups (#29374) * resolved conflicts Signed-off-by: Agnieszka Gancarczyk * added localization feature to up attributes groups Signed-off-by: Agnieszka Gancarczyk * refactor Signed-off-by: Agnieszka Gancarczyk * fix linting Signed-off-by: Jon Koops * fixed attribute groups test Signed-off-by: Agnieszka Gancarczyk * fixed another failing test Signed-off-by: Agnieszka Gancarczyk * reverted the test change Signed-off-by: Agnieszka Gancarczyk --------- Signed-off-by: Agnieszka Gancarczyk Signed-off-by: Jon Koops Co-authored-by: Agnieszka Gancarczyk Co-authored-by: Jon Koops --- .../admin-ui/cypress/e2e/clients_test.spec.ts | 4 +- .../support/pages/admin-ui/ListingPage.ts | 13 +- .../admin/messages/messages_en.properties | 4 + .../realm-settings/NewAttributeSettings.tsx | 21 +- .../src/realm-settings/RealmSettingsTabs.tsx | 32 +- .../user-profile/AttributesGroupForm.tsx | 510 +++++++++++++++++- .../user-profile/AttributesGroupTab.tsx | 88 ++- .../user-profile/AttributesTab.tsx | 50 +- .../user-profile/UserProfileTab.tsx | 2 +- .../attribute/AddTranslationsDialog.tsx | 33 +- .../attribute/AttributeGeneralSettings.tsx | 12 +- js/apps/admin-ui/src/utils/useLocale.ts | 40 ++ 12 files changed, 674 insertions(+), 135 deletions(-) create mode 100644 js/apps/admin-ui/src/utils/useLocale.ts diff --git a/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts b/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts index ebc16e91eb..153b027737 100644 --- a/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts +++ b/js/apps/admin-ui/cypress/e2e/clients_test.spec.ts @@ -571,7 +571,9 @@ describe("Clients test", () => { cy.findByTestId("importClient").click(); cy.findByTestId("realm-file").selectFile( "cypress/fixtures/partial-import-test-data/import-identical-client.json", - { action: "drag-drop" }, + { + action: "drag-drop", + }, ); cy.wait(1000); diff --git a/js/apps/admin-ui/cypress/support/pages/admin-ui/ListingPage.ts b/js/apps/admin-ui/cypress/support/pages/admin-ui/ListingPage.ts index 6a79020e3b..6a78a72c1a 100644 --- a/js/apps/admin-ui/cypress/support/pages/admin-ui/ListingPage.ts +++ b/js/apps/admin-ui/cypress/support/pages/admin-ui/ListingPage.ts @@ -60,9 +60,10 @@ export default class ListingPage extends CommonElements { #tableNameColumnPrefix = "name-column-"; #rowGroup = "table:visible tbody[role='rowgroup']"; #tableHeaderCheckboxItemAllRows = "input[aria-label='Select all rows']"; - #searchBtnInModal = ".pf-v5-c-modal-box .pf-v5-c-toolbar__content-section button.pf-m-control:visible"; + #menuContent = ".pf-v5-c-menu__content"; + #menuItemText = ".pf-v5-c-menu__item-text"; #getSearchInput() { return cy.findAllByTestId("table-search-input").last().find("input"); @@ -189,6 +190,14 @@ export default class ListingPage extends CommonElements { return this; } + clickMenuDelete() { + cy.get(this.#menuContent) + .find(this.#menuItemText) + .contains("Delete") + .click({ force: true }); + return this; + } + clickItemCheckbox(itemName: string) { cy.get(this.#itemsRows) .contains(itemName) @@ -247,7 +256,7 @@ export default class ListingPage extends CommonElements { deleteItem(itemName: string) { this.clickRowDetails(itemName); - this.clickDetailMenu("Delete"); + this.clickMenuDelete(); return this; } diff --git a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties index db461f804b..ef6729447f 100644 --- a/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties +++ b/js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties @@ -3085,9 +3085,13 @@ sendClientIdOnLogout=Send 'client_id' in logout requests sendClientIdOnLogoutHelp=If the 'client_id' parameter should be sent in logout requests. addAttributeTranslationBtn=Add translation button addAttributeTranslationInfo=Add translations for this field using the icon next to the "Display name" field. +addAttributeDisplayNameTranslation=Add translation for the display name +addAttributeDisplayDescriptionTranslation=Add translation for the display description addTranslationsModalTitle=Add translations addTranslationsModalSubTitle=You are able to translate the "Display name" based on your locale or preferred languages. In addition, you are also able to create or edit the "Display name" translations in the addTranslationsModalSubTitleBolded=Realm settings > Localization > Realm overrides. +addTranslationsModalSubTitleDescription=You are able to translate the "Display description" based on your locale or preferred languages. In addition, you are also able to create or edit the "Display description" translations in the +addAttributesGroupTranslationInfo=Add translations for this field using the icon next to the "Display name" field. translationKey=Key translationsTableHeading=Translations searchForLanguage=Search for language diff --git a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx index fc9a5acef5..d5df4a649b 100644 --- a/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx +++ b/js/apps/admin-ui/src/realm-settings/NewAttributeSettings.tsx @@ -10,7 +10,7 @@ import { PageSection, } from "@patternfly/react-core"; import { flatten } from "flat"; -import { useMemo, useState } from "react"; +import { useState } from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link, useNavigate } from "react-router-dom"; @@ -29,8 +29,7 @@ import { AttributeAnnotations } from "./user-profile/attribute/AttributeAnnotati import { AttributeGeneralSettings } from "./user-profile/attribute/AttributeGeneralSettings"; import { AttributePermission } from "./user-profile/attribute/AttributePermission"; import { AttributeValidations } from "./user-profile/attribute/AttributeValidations"; -import { DEFAULT_LOCALE } from "../i18n/i18n"; - +import useLocale from "../utils/useLocale"; import "./realm-settings-section.css"; type TranslationForm = { @@ -157,10 +156,10 @@ const CreateAttributeFormContent = ({ export default function NewAttributeSettings() { const { adminClient } = useAdminClient(); - const { realm: realmName, attributeName } = useParams(); const form = useForm(); const { t } = useTranslation(); + const combinedLocales = useLocale(); const navigate = useNavigate(); const { addAlert, addError } = useAlerts(); const [config, setConfig] = useState(null); @@ -172,20 +171,6 @@ export default function NewAttributeSettings() { const [generatedDisplayName, setGeneratedDisplayName] = useState(""); const [realm, setRealm] = useState(); - const defaultSupportedLocales = useMemo(() => { - return realm?.supportedLocales?.length - ? realm.supportedLocales - : [DEFAULT_LOCALE]; - }, [realm]); - - const defaultLocales = useMemo(() => { - return realm?.defaultLocale?.length ? [realm.defaultLocale] : []; - }, [realm]); - - const combinedLocales = useMemo(() => { - return Array.from(new Set([...defaultLocales, ...defaultSupportedLocales])); - }, [defaultLocales, defaultSupportedLocales]); - useFetch( () => adminClient.realms.findOne({ realm: realmName }), (realm) => { diff --git a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx index bbb6983967..1d05a2bc16 100644 --- a/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx +++ b/js/apps/admin-ui/src/realm-settings/RealmSettingsTabs.tsx @@ -1,7 +1,7 @@ import { fetchWithError } from "@keycloak/keycloak-admin-client"; -import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; import { AdminEnvironment, useEnvironment } from "@keycloak/keycloak-ui-shared"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { AlertVariant, ButtonVariant, @@ -14,11 +14,10 @@ import { DropdownItem, DropdownSeparator, } from "@patternfly/react-core/deprecated"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; -import { useAdminClient } from "../admin-client"; import { useAlerts } from "../components/alert/Alerts"; import { useConfirmDialog } from "../components/confirm-dialog/ConfirmDialog"; import type { KeyValueType } from "../components/key-value-form/key-value-convert"; @@ -28,17 +27,16 @@ import { } from "../components/routable-tabs/RoutableTabs"; import { ViewHeader } from "../components/view-header/ViewHeader"; import { useRealms } from "../context/RealmsContext"; -import { useAccess } from "../context/access/Access"; import { useRealm } from "../context/realm-context/RealmContext"; import { toDashboard } from "../dashboard/routes/Dashboard"; import helpUrls from "../help-urls"; -import { DEFAULT_LOCALE } from "../i18n/i18n"; import { convertFormValuesToObject, convertToFormValues } from "../util"; import { getAuthorizationHeaders } from "../utils/getAuthorizationHeaders"; import { joinPath } from "../utils/joinPath"; import useIsFeatureEnabled, { Feature } from "../utils/useIsFeatureEnabled"; import { RealmSettingsEmailTab } from "./EmailTab"; import { RealmSettingsGeneralTab } from "./GeneralTab"; +import { LocalizationTab } from "./localization/LocalizationTab"; import { RealmSettingsLoginTab } from "./LoginTab"; import { PartialExportDialog } from "./PartialExport"; import { PartialImportDialog } from "./PartialImport"; @@ -50,11 +48,13 @@ import { RealmSettingsTokensTab } from "./TokensTab"; import { UserRegistration } from "./UserRegistration"; import { EventsTab } from "./event-config/EventsTab"; import { KeysTab } from "./keys/KeysTab"; -import { LocalizationTab } from "./localization/LocalizationTab"; import { ClientPoliciesTab, toClientPolicies } from "./routes/ClientPolicies"; import { RealmSettingsTab, toRealmSettings } from "./routes/RealmSettings"; import { SecurityDefenses } from "./security-defences/SecurityDefenses"; import { UserProfileTab } from "./user-profile/UserProfileTab"; +import useLocale from "../utils/useLocale"; +import { useAdminClient } from "../admin-client"; +import { useAccess } from "../context/access/Access"; export interface UIRealmRepresentation extends RealmRepresentation { upConfig?: UserProfileConfig; @@ -77,14 +77,12 @@ const RealmSettingsHeader = ({ }: RealmSettingsHeaderProps) => { const { adminClient } = useAdminClient(); const { environment } = useEnvironment(); - const { t } = useTranslation(); const { refresh: refreshRealms } = useRealms(); const { addAlert, addError } = useAlerts(); const navigate = useNavigate(); const [partialImportOpen, setPartialImportOpen] = useState(false); const [partialExportOpen, setPartialExportOpen] = useState(false); - const { hasAccess } = useAccess(); const canManageRealm = hasAccess("manage-realm"); @@ -187,22 +185,20 @@ export const RealmSettingsTabs = ({ refresh, }: RealmSettingsTabsProps) => { const { adminClient } = useAdminClient(); - const { t } = useTranslation(); const { addAlert, addError } = useAlerts(); const { realm: realmName } = useRealm(); const { refresh: refreshRealms } = useRealms(); + const combinedLocales = useLocale(); const navigate = useNavigate(); const isFeatureEnabled = useIsFeatureEnabled(); const [tableData, setTableData] = useState< Record[] | undefined >(undefined); - const { control, setValue, getValues } = useForm({ mode: "onChange", }); const [key, setKey] = useState(0); - const refreshHeader = () => { setKey(key + 1); }; @@ -211,20 +207,6 @@ export const RealmSettingsTabs = ({ convertToFormValues(r, setValue); }; - const defaultSupportedLocales = useMemo(() => { - return realm.supportedLocales?.length - ? realm.supportedLocales - : [DEFAULT_LOCALE]; - }, [realm]); - - const defaultLocales = useMemo(() => { - return realm.defaultLocale?.length ? [realm.defaultLocale] : []; - }, [realm]); - - const combinedLocales = useMemo(() => { - return Array.from(new Set([...defaultLocales, ...defaultSupportedLocales])); - }, [defaultLocales, defaultSupportedLocales]); - useEffect(() => { setupForm(); const fetchLocalizationTexts = async () => { diff --git a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx index a9be43669a..9d1b299242 100644 --- a/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx +++ b/js/apps/admin-ui/src/realm-settings/user-profile/AttributesGroupForm.tsx @@ -1,18 +1,28 @@ import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata"; +import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { ActionGroup, + Alert, Button, FormGroup, + Grid, + GridItem, PageSection, Text, TextContent, + TextInput, } from "@patternfly/react-core"; -import { useEffect, useMemo } from "react"; -import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + FormProvider, + SubmitHandler, + useForm, + useWatch, +} from "react-hook-form"; import { useTranslation } from "react-i18next"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { TextControl } from "@keycloak/keycloak-ui-shared"; - +import { HelpItem, TextControl } from "@keycloak/keycloak-ui-shared"; +import { useAlerts } from "../../components/alert/Alerts"; import { FormAccess } from "../../components/form/FormAccess"; import { KeyValueInput } from "../../components/key-value-form/KeyValueInput"; import type { KeyValueType } from "../../components/key-value-form/key-value-convert"; @@ -21,8 +31,16 @@ import { useRealm } from "../../context/realm-context/RealmContext"; import type { EditAttributesGroupParams } from "../routes/EditAttributesGroup"; import { toUserProfile } from "../routes/UserProfile"; import { useUserProfile } from "./UserProfileContext"; - +import { useFetch } from "../../utils/useFetch"; +import { GlobeRouteIcon } from "@patternfly/react-icons"; +import useToggle from "../../utils/useToggle"; +import useLocale from "../../utils/useLocale"; +import { + AddTranslationsDialog, + TranslationsType, +} from "./attribute/AddTranslationsDialog"; import "../realm-settings-section.css"; +import { useAdminClient } from "../../admin-client"; function parseAnnotations(input: Record): KeyValueType[] { return Object.entries(input).reduce((p, [key, value]) => { @@ -46,6 +64,21 @@ type FormFields = Required> & { annotations: KeyValueType[]; }; +type TranslationForm = { + locale: string; + value: string; +}; + +type Translations = { + key: string; + translations: TranslationForm[]; +}; + +type TranslationsSets = { + displayHeader: Translations; + displayDescription: Translations; +}; + const defaultValues: FormFields = { annotations: [], displayDescription: "", @@ -54,12 +87,39 @@ const defaultValues: FormFields = { }; export default function AttributesGroupForm() { + const { adminClient } = useAdminClient(); const { t } = useTranslation(); - const { realm } = useRealm(); + const { realm: realmName } = useRealm(); const { config, save } = useUserProfile(); const navigate = useNavigate(); + const combinedLocales = useLocale(); const params = useParams(); const form = useForm({ defaultValues }); + const [realm, setRealm] = useState(); + const { addError } = useAlerts(); + const editMode = params.name ? true : false; + const [newAttributesGroupName, setNewAttributesGroupName] = useState(""); + const [ + generatedAttributesGroupDisplayName, + setGeneratedAttributesGroupDisplayName, + ] = useState(""); + const [ + generatedAttributesGroupDisplayDescription, + setGeneratedAttributesGroupDisplayDescription, + ] = useState(""); + const [addTranslationsModalOpen, toggleModal] = useToggle(); + const regexPattern = /\$\{([^}]+)\}/; + const [type, setType] = useState(); + const [translationsData, setTranslationsData] = useState({ + displayHeader: { + key: "", + translations: [], + }, + displayDescription: { + key: "", + translations: [], + }, + }); const matchingGroup = useMemo( () => config?.groups?.find(({ name }) => name === params.name), @@ -78,6 +138,185 @@ export default function AttributesGroupForm() { form.reset({ ...defaultValues, ...matchingGroup, annotations }); }, [matchingGroup]); + useEffect(() => { + form.setValue( + "displayHeader", + matchingGroup + ? matchingGroup.displayHeader! + : generatedAttributesGroupDisplayName, + ); + form.setValue( + "displayDescription", + matchingGroup + ? matchingGroup.displayDescription! + : generatedAttributesGroupDisplayDescription, + ); + }, [ + generatedAttributesGroupDisplayName, + generatedAttributesGroupDisplayDescription, + ]); + + useFetch( + () => adminClient.realms.findOne({ realm: realmName }), + (realm) => { + if (!realm) { + throw new Error(t("notFound")); + } + setRealm(realm); + }, + [], + ); + + useFetch( + async () => { + const translationsToSaveDisplayHeader: Translations[] = []; + const translationsToSaveDisplayDescription: Translations[] = []; + const formData = form.getValues(); + + const translationsResults = await Promise.all( + combinedLocales.map(async (selectedLocale) => { + try { + const translations = + await adminClient.realms.getRealmLocalizationTexts({ + realm: realmName, + selectedLocale, + }); + + const formattedDisplayHeaderKey = formData.displayHeader?.substring( + 2, + formData.displayHeader.length - 1, + ); + const formattedDisplayDescriptionKey = + formData.displayDescription?.substring( + 2, + formData.displayDescription.length - 1, + ); + + return { + locale: selectedLocale, + headerTranslation: translations[formattedDisplayHeaderKey] ?? "", + descriptionTranslation: + translations[formattedDisplayDescriptionKey] ?? "", + }; + } catch (error) { + console.error( + `Error fetching translations for ${selectedLocale}:`, + error, + ); + return null; + } + }), + ); + + translationsResults.forEach((translationsResult) => { + if (translationsResult) { + const { locale, headerTranslation, descriptionTranslation } = + translationsResult; + translationsToSaveDisplayHeader.push({ + key: formData.displayHeader?.substring( + 2, + formData.displayHeader.length - 1, + ), + translations: [ + { + locale, + value: headerTranslation, + }, + ], + }); + translationsToSaveDisplayDescription.push({ + key: formData.displayDescription?.substring( + 2, + formData.displayDescription.length - 1, + ), + translations: [ + { + locale, + value: descriptionTranslation, + }, + ], + }); + } + }); + + return { + translationsToSaveDisplayHeader, + translationsToSaveDisplayDescription, + }; + }, + (data) => { + setTranslationsData({ + displayHeader: { + key: data.translationsToSaveDisplayHeader[0].key, + translations: data.translationsToSaveDisplayHeader.flatMap( + (translationData) => translationData.translations, + ), + }, + displayDescription: { + key: data.translationsToSaveDisplayDescription[0].key, + translations: data.translationsToSaveDisplayDescription.flatMap( + (translationData) => translationData.translations, + ), + }, + }); + }, + [combinedLocales], + ); + + const saveTranslations = async () => { + const addLocalization = async ( + key: string, + locale: string, + value: string, + ) => { + try { + await adminClient.realms.addLocalization( + { + realm: realmName, + selectedLocale: locale, + key: key, + }, + value, + ); + } catch (error) { + console.error( + `Error saving translation for locale ${locale}: ${error}`, + ); + } + }; + + try { + if ( + translationsData.displayHeader && + translationsData.displayHeader.translations.length > 0 + ) { + for (const translation of translationsData.displayHeader.translations) { + await addLocalization( + translationsData.displayHeader.key, + translation.locale, + translation.value, + ); + } + } + + if ( + translationsData.displayDescription && + translationsData.displayDescription.translations.length > 0 + ) { + for (const translation of translationsData.displayDescription + .translations) { + await addLocalization( + translationsData.displayDescription.key, + translation.locale, + translation.value, + ); + } + } + } catch (error) { + console.error(`Error while processing translations: ${error}`); + } + }; + const onSubmit: SubmitHandler = async (values) => { if (!config) { return; @@ -96,15 +335,133 @@ export default function AttributesGroupForm() { groups[updateAt] = updatedGroup; } + if (realm?.internationalizationEnabled) { + const hasNonEmptyDisplayHeaderTranslations = + translationsData.displayHeader.translations.some( + (translation) => translation.value.trim() !== "", + ); + + const hasNonEmptyDisplayDescriptionTranslations = + translationsData.displayDescription.translations.some( + (translation) => translation.value.trim() !== "", + ); + + if ( + !hasNonEmptyDisplayHeaderTranslations || + !hasNonEmptyDisplayDescriptionTranslations + ) { + addError("createAttributeError", t("translationError")); + return; + } + } + const success = await save({ ...config, groups }); if (success) { - navigate(toUserProfile({ realm, tab: "attributes-group" })); + await saveTranslations(); + navigate(toUserProfile({ realm: realmName, tab: "attributes-group" })); } }; + const attributesGroupDisplayName = useWatch({ + control: form.control, + name: "displayHeader", + }); + + const attributesGroupDisplayDescription = useWatch({ + control: form.control, + name: "displayDescription", + }); + + const handleAttributesGroupNameChange = ( + event: React.FormEvent, + value: string, + ) => { + const newDisplayName = + value !== "" && realm?.internationalizationEnabled + ? "${profile.attribute-group." + `${value}}` + : ""; + const newDisplayDescription = + value !== "" && realm?.internationalizationEnabled + ? "${profile.attribute-group-description." + `${value}}` + : ""; + setNewAttributesGroupName(value); + setGeneratedAttributesGroupDisplayName(newDisplayName); + setGeneratedAttributesGroupDisplayDescription(newDisplayDescription); + }; + + const attributesGroupDisplayPatternMatch = regexPattern.test( + attributesGroupDisplayName || attributesGroupDisplayDescription, + ); + + const formattedAttributesGroupDisplayName = + attributesGroupDisplayName?.substring( + 2, + attributesGroupDisplayName.length - 1, + ); + const formattedAttributesGroupDisplayDescription = + attributesGroupDisplayDescription?.substring( + 2, + attributesGroupDisplayDescription.length - 1, + ); + + const handleHeaderTranslationsAdded = (headerTranslations: Translations) => { + setTranslationsData((prev) => ({ + ...prev, + displayHeader: headerTranslations, + })); + }; + + const handleDescriptionTranslationsAdded = ( + descriptionTranslations: Translations, + ) => { + setTranslationsData((prev) => ({ + ...prev, + displayDescription: descriptionTranslations, + })); + }; + + const handleToggleDialog = () => { + toggleModal(); + }; + + const groupDisplayNameKey = + type === "displayHeader" + ? formattedAttributesGroupDisplayName + : `profile.attribute-group.${newAttributesGroupName}`; + const groupDisplayDescriptionKey = + type === "displayDescription" + ? formattedAttributesGroupDisplayDescription + : `profile.attribute-group-description.${newAttributesGroupName}`; + return ( <> + {addTranslationsModalOpen && ( + { + toggleModal(); + }} + /> + )} { + handleAttributesGroupNameChange(event, event.target.value); + }, + }} /> {!!matchingGroup && ( )} - - + } + fieldId="kc-attributes-group-display-header" + > + + + + {generatedAttributesGroupDisplayName && ( + + )} + + {realm?.internationalizationEnabled && ( + +