564441899b
* merge all namespaces into one added fallback namespace to the configuration to minimize changes to the keys * small fix * Fix the broken `OverridesBackend` * remove stray console log * restore ns argument * PR review * merge main --------- Co-authored-by: Jon Koops <jonkoops@gmail.com>
612 lines
20 KiB
TypeScript
612 lines
20 KiB
TypeScript
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 {
|
|
EditableTextCell,
|
|
IEditableTextCell,
|
|
IRow,
|
|
IRowCell,
|
|
RowEditType,
|
|
RowErrors,
|
|
Table,
|
|
TableBody,
|
|
TableHeader,
|
|
TableVariant,
|
|
applyCellEdits,
|
|
cancelCellEdits,
|
|
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 { HelpItem } from "ui-shared";
|
|
|
|
import { adminClient } from "../admin-client";
|
|
import { useAlerts } from "../components/alert/Alerts";
|
|
import { FormAccess } from "../components/form/FormAccess";
|
|
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 { useRealm } from "../context/realm-context/RealmContext";
|
|
import { useServerInfo } from "../context/server-info/ServerInfoProvider";
|
|
import { useWhoAmI } from "../context/whoami/WhoAmI";
|
|
import { DEFAULT_LOCALE } from "../i18n/i18n";
|
|
import { convertToFormValues } from "../util";
|
|
import { useFetch } from "../utils/useFetch";
|
|
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 = {
|
|
key: string;
|
|
value: string;
|
|
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();
|
|
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<IRow[]>([]);
|
|
|
|
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<BundleForm>({ 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<IRow>((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) => (
|
|
<EditableTextCell
|
|
value={value!}
|
|
rowIndex={rowIndex!}
|
|
cellIndex={cellIndex!}
|
|
props={props}
|
|
isDisabled
|
|
handleTextInputChange={handleTextInputChange}
|
|
inputAriaLabel={messageBundle[0]}
|
|
/>
|
|
),
|
|
props: {
|
|
value: messageBundle[0],
|
|
},
|
|
},
|
|
{
|
|
title: (value, rowIndex, cellIndex, props) => (
|
|
<EditableTextCell
|
|
value={value!}
|
|
rowIndex={rowIndex!}
|
|
cellIndex={cellIndex!}
|
|
props={props}
|
|
handleTextInputChange={handleTextInputChange}
|
|
inputAriaLabel={messageBundle[1]}
|
|
/>
|
|
),
|
|
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 = [
|
|
<SelectGroup label={t("defaultLocale")} key="group1">
|
|
<SelectOption key={DEFAULT_LOCALE} value={DEFAULT_LOCALE}>
|
|
{localeToDisplayName(DEFAULT_LOCALE)}
|
|
</SelectOption>
|
|
</SelectGroup>,
|
|
<Divider key="divider" />,
|
|
<SelectGroup label={t("supportedLocales")} key="group2">
|
|
{watchSupportedLocales.map((locale) => (
|
|
<SelectOption key={locale} value={locale}>
|
|
{localeToDisplayName(locale)}
|
|
</SelectOption>
|
|
))}
|
|
</SelectGroup>,
|
|
];
|
|
|
|
const addKeyValue = async (pair: KeyValueType): Promise<void> => {
|
|
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 && (
|
|
<AddMessageBundleModal
|
|
handleModalToggle={handleModalToggle}
|
|
save={(pair: any) => {
|
|
addKeyValue(pair);
|
|
handleModalToggle();
|
|
}}
|
|
form={bundleForm}
|
|
/>
|
|
)}
|
|
<PageSection variant="light">
|
|
<FormAccess
|
|
isHorizontal
|
|
role="manage-realm"
|
|
className="pf-u-mt-lg"
|
|
onSubmit={handleSubmit(save)}
|
|
>
|
|
<FormGroup
|
|
label={t("internationalization")}
|
|
fieldId="kc-internationalization"
|
|
labelIcon={
|
|
<HelpItem
|
|
helpText={t("internationalizationHelp")}
|
|
fieldLabelId="realm-settings:internationalization"
|
|
/>
|
|
}
|
|
>
|
|
<Controller
|
|
name="internationalizationEnabled"
|
|
control={control}
|
|
defaultValue={realm.internationalizationEnabled}
|
|
render={({ field }) => (
|
|
<Switch
|
|
id="kc-l-internationalization"
|
|
label={t("common:enabled")}
|
|
labelOff={t("common:disabled")}
|
|
isChecked={field.value}
|
|
data-testid={
|
|
field.value
|
|
? "internationalization-enabled"
|
|
: "internationalization-disabled"
|
|
}
|
|
onChange={field.onChange}
|
|
aria-label={t("internationalization")}
|
|
/>
|
|
)}
|
|
/>
|
|
</FormGroup>
|
|
{internationalizationEnabled && (
|
|
<>
|
|
<FormGroup
|
|
label={t("supportedLocales")}
|
|
fieldId="kc-l-supported-locales"
|
|
>
|
|
<Controller
|
|
name="supportedLocales"
|
|
control={control}
|
|
defaultValue={defaultSupportedLocales}
|
|
render={({ field }) => (
|
|
<Select
|
|
toggleId="kc-l-supported-locales"
|
|
onToggle={(open) => {
|
|
setSupportedLocalesOpen(open);
|
|
}}
|
|
onSelect={(_, v) => {
|
|
const option = v as string;
|
|
if (field.value.includes(option)) {
|
|
field.onChange(
|
|
field.value.filter(
|
|
(item: string) => item !== option,
|
|
),
|
|
);
|
|
} else {
|
|
field.onChange([...field.value, option]);
|
|
}
|
|
}}
|
|
onClear={() => {
|
|
field.onChange([]);
|
|
}}
|
|
selections={field.value}
|
|
variant={SelectVariant.typeaheadMulti}
|
|
aria-label={t("supportedLocales")}
|
|
isOpen={supportedLocalesOpen}
|
|
placeholderText={t("selectLocales")}
|
|
>
|
|
{allLocales.map((locale) => (
|
|
<SelectOption
|
|
selected={field.value.includes(locale)}
|
|
key={locale}
|
|
value={locale}
|
|
>
|
|
{localeToDisplayName(locale)}
|
|
</SelectOption>
|
|
))}
|
|
</Select>
|
|
)}
|
|
/>
|
|
</FormGroup>
|
|
<FormGroup
|
|
label={t("defaultLocale")}
|
|
fieldId="kc-l-default-locale"
|
|
>
|
|
<Controller
|
|
name="defaultLocale"
|
|
control={control}
|
|
defaultValue={DEFAULT_LOCALE}
|
|
render={({ field }) => (
|
|
<Select
|
|
toggleId="kc-default-locale"
|
|
onToggle={() => setDefaultLocaleOpen(!defaultLocaleOpen)}
|
|
onSelect={(_, value) => {
|
|
field.onChange(value as string);
|
|
setDefaultLocaleOpen(false);
|
|
}}
|
|
selections={
|
|
field.value
|
|
? localeToDisplayName(field.value)
|
|
: realm.defaultLocale !== ""
|
|
? localeToDisplayName(
|
|
realm.defaultLocale || DEFAULT_LOCALE,
|
|
)
|
|
: t("placeholderText")
|
|
}
|
|
variant={SelectVariant.single}
|
|
aria-label={t("defaultLocale")}
|
|
isOpen={defaultLocaleOpen}
|
|
placeholderText={t("placeholderText")}
|
|
data-testid="select-default-locale"
|
|
>
|
|
{watchSupportedLocales.map((locale, idx) => (
|
|
<SelectOption
|
|
key={`default-locale-${idx}`}
|
|
value={locale}
|
|
>
|
|
{localeToDisplayName(locale)}
|
|
</SelectOption>
|
|
))}
|
|
</Select>
|
|
)}
|
|
/>
|
|
</FormGroup>
|
|
</>
|
|
)}
|
|
<ActionGroup>
|
|
<Button
|
|
variant="primary"
|
|
isDisabled={!formState.isDirty}
|
|
type="submit"
|
|
data-testid="localization-tab-save"
|
|
>
|
|
{t("common:save")}
|
|
</Button>
|
|
<Button variant="link" onClick={setupForm}>
|
|
{t("common:revert")}
|
|
</Button>
|
|
</ActionGroup>
|
|
</FormAccess>
|
|
|
|
<FormPanel className="kc-message-bundles" title="Edit message bundles">
|
|
<TextContent className="messageBundleDescription">
|
|
{t("messageBundleDescription")}
|
|
</TextContent>
|
|
<div className="tableBorder">
|
|
<PaginatingTableToolbar
|
|
count={messageBundles.length}
|
|
first={first}
|
|
max={max}
|
|
onNextClick={setFirst}
|
|
onPreviousClick={setFirst}
|
|
onPerPageSelect={(first, max) => {
|
|
setFirst(first);
|
|
setMax(max);
|
|
}}
|
|
inputGroupName={"common:search"}
|
|
inputGroupOnEnter={(search) => {
|
|
setFilter(search);
|
|
setFirst(0);
|
|
setMax(10);
|
|
}}
|
|
inputGroupPlaceholder={t("searchForMessageBundle")}
|
|
toolbarItem={
|
|
<Button
|
|
data-testid="add-bundle-button"
|
|
onClick={() => setAddMessageBundleModalOpen(true)}
|
|
>
|
|
{t("addMessageBundle")}
|
|
</Button>
|
|
}
|
|
searchTypeComponent={
|
|
<ToolbarItem>
|
|
<Select
|
|
width={180}
|
|
data-testid="filter-by-locale-select"
|
|
isOpen={filterDropdownOpen}
|
|
className="kc-filter-by-locale-select"
|
|
variant={SelectVariant.single}
|
|
isDisabled={!internationalizationEnabled}
|
|
onToggle={(isExpanded) => setFilterDropdownOpen(isExpanded)}
|
|
onSelect={(_, value) => {
|
|
setSelectMenuLocale(value.toString());
|
|
setSelectMenuValueSelected(true);
|
|
refreshTable();
|
|
setFilterDropdownOpen(false);
|
|
}}
|
|
selections={
|
|
selectMenuValueSelected
|
|
? localeToDisplayName(selectMenuLocale)
|
|
: realm.defaultLocale !== ""
|
|
? localeToDisplayName(DEFAULT_LOCALE)
|
|
: t("placeholderText")
|
|
}
|
|
>
|
|
{options}
|
|
</Select>
|
|
</ToolbarItem>
|
|
}
|
|
>
|
|
{messageBundles.length === 0 && !filter && (
|
|
<ListEmptyState
|
|
hasIcon
|
|
message={t("noMessageBundles")}
|
|
instructions={t("noMessageBundlesInstructions")}
|
|
onPrimaryAction={handleModalToggle}
|
|
/>
|
|
)}
|
|
{messageBundles.length === 0 && filter && (
|
|
<ListEmptyState
|
|
hasIcon
|
|
icon={SearchIcon}
|
|
isSearchVariant
|
|
message={t("common:noSearchResults")}
|
|
instructions={t("common:noSearchResultsInstructions")}
|
|
/>
|
|
)}
|
|
{messageBundles.length !== 0 && (
|
|
<Table
|
|
aria-label={t("editableRowsTable")}
|
|
data-testid="editable-rows-table"
|
|
variant={TableVariant.compact}
|
|
cells={[t("common:key"), t("common:value")]}
|
|
rows={tableRows}
|
|
onRowEdit={(_, type, _b, rowIndex, validation) =>
|
|
updateEditableRows(type, rowIndex, validation)
|
|
}
|
|
actions={[
|
|
{
|
|
title: t("common:delete"),
|
|
onClick: (_, row) =>
|
|
deleteKey(
|
|
(tableRows[row].cells?.[0] as IRowCell).props.value,
|
|
),
|
|
},
|
|
]}
|
|
>
|
|
<TableHeader />
|
|
<TableBody />
|
|
</Table>
|
|
)}
|
|
</PaginatingTableToolbar>
|
|
</div>
|
|
</FormPanel>
|
|
</PageSection>
|
|
</>
|
|
);
|
|
};
|