import type RealmRepresentation from "@keycloak/keycloak-admin-client/lib/defs/realmRepresentation"; import { ActionGroup, AlertVariant, Button, Divider, FormGroup, PageSection, Select, SelectGroup, SelectOption, SelectVariant, Switch, TextContent, ToolbarItem, } from "@patternfly/react-core"; import { SearchIcon } from "@patternfly/react-icons"; import { applyCellEdits, cancelCellEdits, EditableTextCell, IEditableTextCell, IRow, IRowCell, RowEditType, RowErrors, Table, TableBody, TableHeader, TableVariant, validateCellEdits, } from "@patternfly/react-table"; import { cloneDeep, isEqual, uniqWith } from "lodash-es"; import { useEffect, useMemo, useState } from "react"; import { Controller, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useAlerts } from "../components/alert/Alerts"; import { FormAccess } from "../components/form-access/FormAccess"; import { HelpItem } from "../components/help-enabler/HelpItem"; import type { KeyValueType } from "../components/key-value-form/key-value-convert"; import { ListEmptyState } from "../components/list-empty-state/ListEmptyState"; import { FormPanel } from "../components/scroll-form/FormPanel"; import { PaginatingTableToolbar } from "../components/table-toolbar/PaginatingTableToolbar"; import { useAdminClient, useFetch } from "../context/auth/AdminClient"; import { useRealm } from "../context/realm-context/RealmContext"; import { useServerInfo } from "../context/server-info/ServerInfoProvider"; import { useWhoAmI } from "../context/whoami/WhoAmI"; import { DEFAULT_LOCALE } from "../i18n"; import { convertToFormValues } from "../util"; import { AddMessageBundleModal } from "./AddMessageBundleModal"; type LocalizationTabProps = { save: (realm: RealmRepresentation) => void; refresh: () => void; realm: RealmRepresentation; }; export enum RowEditAction { Save = "save", Cancel = "cancel", Edit = "edit", Delete = "delete", } export type BundleForm = { messageBundle: KeyValueType; }; const localeToDisplayName = (locale: string) => { try { return new Intl.DisplayNames([locale], { type: "language" }).of(locale); } catch (error) { return locale; } }; export const LocalizationTab = ({ save, realm }: LocalizationTabProps) => { const { t } = useTranslation("realm-settings"); const { adminClient } = useAdminClient(); const [addMessageBundleModalOpen, setAddMessageBundleModalOpen] = useState(false); const [supportedLocalesOpen, setSupportedLocalesOpen] = useState(false); const [defaultLocaleOpen, setDefaultLocaleOpen] = useState(false); const [filterDropdownOpen, setFilterDropdownOpen] = useState(false); const [selectMenuLocale, setSelectMenuLocale] = useState(DEFAULT_LOCALE); const { setValue, getValues, control, handleSubmit, formState } = useForm(); const [selectMenuValueSelected, setSelectMenuValueSelected] = useState(false); const [messageBundles, setMessageBundles] = useState<[string, string][]>([]); const [tableRows, setTableRows] = useState([]); const themeTypes = useServerInfo().themes!; const allLocales = useMemo(() => { const locales = Object.values(themeTypes).flatMap((theme) => theme.flatMap(({ locales }) => (locales ? locales : [])) ); return Array.from(new Set(locales)); }, [themeTypes]); const bundleForm = useForm({ mode: "onChange" }); const { addAlert, addError } = useAlerts(); const { realm: currentRealm } = useRealm(); const { whoAmI } = useWhoAmI(); const defaultSupportedLocales = realm.supportedLocales?.length ? realm.supportedLocales : [DEFAULT_LOCALE]; const setupForm = () => { convertToFormValues(realm, setValue); setValue("supportedLocales", defaultSupportedLocales); }; useEffect(setupForm, []); const watchSupportedLocales: string[] = useWatch({ control, name: "supportedLocales", defaultValue: defaultSupportedLocales, }); const internationalizationEnabled = useWatch({ control, name: "internationalizationEnabled", defaultValue: realm.internationalizationEnabled, }); const [tableKey, setTableKey] = useState(0); const [max, setMax] = useState(10); const [first, setFirst] = useState(0); const [filter, setFilter] = useState(""); const refreshTable = () => { setTableKey(tableKey + 1); }; useFetch( async () => { let result = await adminClient.realms .getRealmLocalizationTexts({ first, max, realm: realm.realm!, selectedLocale: selectMenuLocale || getValues("defaultLocale") || whoAmI.getLocale(), }) // prevents server error in dev mode due to snowpack .catch(() => []); const searchInBundles = (idx: number) => { return Object.entries(result).filter((i) => i[idx].includes(filter)); }; if (filter) { const filtered = uniqWith( searchInBundles(0).concat(searchInBundles(1)), isEqual ); result = Object.fromEntries(filtered); } return { result }; }, ({ result }) => { const bundles = Object.entries(result).slice(first, first + max + 1); setMessageBundles(bundles); const updatedRows = bundles.map((messageBundle) => ({ rowEditBtnAriaLabel: () => t("rowEditBtnAriaLabel", { messageBundle: messageBundle[1], }), rowSaveBtnAriaLabel: () => t("rowSaveBtnAriaLabel", { messageBundle: messageBundle[1], }), rowCancelBtnAriaLabel: () => t("rowCancelBtnAriaLabel", { messageBundle: messageBundle[1], }), cells: [ { title: (value, rowIndex, cellIndex, props) => ( ), props: { value: messageBundle[0], }, }, { title: (value, rowIndex, cellIndex, props) => ( ), props: { value: messageBundle[1], }, }, ], })); setTableRows(updatedRows); return bundles; }, [tableKey, filter, first, max] ); const handleTextInputChange = ( newValue: string, evt: any, rowIndex: number, cellIndex: number ) => { setTableRows((prev) => { const newRows = cloneDeep(prev); const textCell = newRows[rowIndex]?.cells?.[ cellIndex ] as IEditableTextCell; textCell.props.editableValue = newValue; return newRows; }); }; const updateEditableRows = async ( type: RowEditType, rowIndex?: number, validationErrors?: RowErrors ) => { if (rowIndex === undefined) { return; } const newRows = cloneDeep(tableRows); let newRow: IRow; const invalid = !!validationErrors && Object.keys(validationErrors).length > 0; if (invalid) { newRow = validateCellEdits(newRows[rowIndex], type, validationErrors); } else if (type === RowEditAction.Cancel) { newRow = cancelCellEdits(newRows[rowIndex]); } else { newRow = applyCellEdits(newRows[rowIndex], type); } newRows[rowIndex] = newRow; // Update the copy of the retrieved data set so we can save it when the user saves changes if (!invalid && type === RowEditAction.Save) { const key = (newRow.cells?.[0] as IRowCell).props.value; const value = (newRow.cells?.[1] as IRowCell).props.value; // We only have one editable value, otherwise we'd need to save each try { await adminClient.realms.addLocalization( { realm: realm.realm!, selectedLocale: selectMenuLocale || getValues("defaultLocale") || DEFAULT_LOCALE, key, }, value ); addAlert(t("updateMessageBundleSuccess"), AlertVariant.success); } catch (error) { addAlert(t("updateMessageBundleError"), AlertVariant.danger); } } setTableRows(newRows); }; const handleModalToggle = () => { setAddMessageBundleModalOpen(!addMessageBundleModalOpen); }; const options = [ {localeToDisplayName(DEFAULT_LOCALE)} , , {watchSupportedLocales.map((locale) => ( {localeToDisplayName(locale)} ))} , ]; const addKeyValue = async (pair: KeyValueType): Promise => { try { await adminClient.realms.addLocalization( { realm: currentRealm!, selectedLocale: selectMenuLocale || getValues("defaultLocale") || DEFAULT_LOCALE, key: pair.key, }, pair.value ); adminClient.setConfig({ realmName: currentRealm!, }); refreshTable(); addAlert(t("addMessageBundleSuccess"), AlertVariant.success); } catch (error) { addError(t("addMessageBundleError"), error); } }; const deleteKey = async (key: string) => { try { await adminClient.realms.deleteRealmLocalizationTexts({ realm: currentRealm!, selectedLocale: selectMenuLocale, key, }); refreshTable(); addAlert(t("deleteMessageBundleSuccess")); } catch (error) { addError("realm-settings:deleteMessageBundleError", error); } }; return ( <> {addMessageBundleModalOpen && ( { addKeyValue(pair); handleModalToggle(); }} form={bundleForm} /> )} } > ( )} /> {internationalizationEnabled && ( <> ( )} /> ( )} /> )} {t("messageBundleDescription")}
{ setFirst(first); setMax(max); }} inputGroupName={"common:search"} inputGroupOnEnter={(search) => { setFilter(search); setFirst(0); setMax(10); }} inputGroupPlaceholder={t("searchForMessageBundle")} toolbarItem={ } searchTypeComponent={ } > {messageBundles.length === 0 && !filter && ( )} {messageBundles.length === 0 && filter && ( )} {messageBundles.length !== 0 && ( updateEditableRows(type, rowIndex, validation) } actions={[ { title: t("common:delete"), onClick: (_, row) => deleteKey( (tableRows[row].cells?.[0] as IRowCell).props.value ), }, ]} >
)}
); };