translation refactor (#35292)

* more reusable translation dialog

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* introduced reusable component for translations

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

* fixed tests

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>

---------

Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
This commit is contained in:
Erik Jan de Wit 2024-12-03 19:22:05 +01:00 committed by GitHub
parent 86c475b9fc
commit d65f896d82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 675 additions and 1328 deletions

View File

@ -14,8 +14,8 @@ export default class UserProfile {
#deleteDrpDwnOption = "deleteDropdownAttributeItem";
#editDrpDwnOption = "editDropdownAttributeItem";
#cancelNewAttribute = "attribute-cancel";
#newAttributeNameInput = "attribute-name";
#newAttributeDisplayNameInput = "attribute-display-name";
#newAttributeNameInput = "name";
#newAttributeDisplayNameInput = "attributes-displayName";
#newAttributeEnabledWhen = 'input[name="enabledWhen"]';
#newAttributeEmptyValidators = ".kc-emptyValidators";
#newAttributeAnnotationBtn = "annotations-add-row";

View File

@ -458,7 +458,7 @@ noTokens=No initial access tokens
addMapper=Add mapper
webauthnPolicy=Webauthn Policy
userAttributeName=User attribute name to store SAML attribute. Use email, lastName, and firstName to map to those predefined user properties.
displayDescriptionField=Display description
displayDescription=Display description
eventTypes.DELETE_ACCOUNT.description=Delete account
eventTypes.RESTART_AUTHENTICATION_ERROR.description=Restart authentication error
evictionHour=Eviction hour
@ -871,6 +871,7 @@ testAuthentication=Test authentication
groupNameLdapAttributeHelp=Name of LDAP attribute, which is used in group objects for name and RDN of group. Usually it will be 'cn'. In this case typical group/role object may have DN like 'cn\=Group1,ouu\=groups,dc\=example,dc\=org'.
deleteError=Could not delete the provider {{error}}
attributeDisplayName=Display name
displayName=Display name
pkceEnabled=Use PKCE
userProviderSaveSuccess=User federation provider successfully saved
month=Month
@ -1472,7 +1473,7 @@ roleHelpHelp=Role to grant to user. Click 'Select Role' button to browse roles,
storedTokensReadable=Stored tokens readable
defaultRoleDeleteError=You cannot delete a default role.
unknownUser=Anonymous
displayHeaderField=Display name
displayHeader=Display name
userVerify.not\ specified=Not specified
usermodel.prop.label=Property
userFedUnlinkUsersConfirm=Do you want to unlink all the users? Any users without a password in the database will not be able to authenticate anymore.
@ -3121,14 +3122,12 @@ sendIdTokenOnLogoutHelp=If the 'id_token_hint' parameter should be sent in logou
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
addAttributeTranslationInfo=Add translations for this field using the icon next to the "{{fieldName}}" field.
addAttributeTranslation=Add translation for the "{{fieldName}}" field
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
addTranslationsModalSubTitle=You are able to translate the "{{fieldName}}" based on your locale or preferred languages. In addition, you are also able to create or edit the "{{fieldName}}" 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

View File

@ -18,29 +18,22 @@ import { Link, useNavigate } from "react-router-dom";
import { useAdminClient } from "../admin-client";
import { FixedButtonsGroup } from "../components/form/FixedButtonGroup";
import { ViewHeader } from "../components/view-header/ViewHeader";
import { useRealm } from "../context/realm-context/RealmContext";
import { i18n } from "../i18n/i18n";
import { convertToFormValues } from "../util";
import useLocale from "../utils/useLocale";
import { useParams } from "../utils/useParams";
import "./realm-settings-section.css";
import { TranslationForm } from "./AddTranslationModal";
import type { AttributeParams } from "./routes/Attribute";
import { toUserProfile } from "./routes/UserProfile";
import { UserProfileProvider } from "./user-profile/UserProfileContext";
import {
saveTranslations,
Translations,
} from "./user-profile/attribute/TranslatableField";
import { AttributeAnnotations } from "./user-profile/attribute/AttributeAnnotations";
import { AttributeGeneralSettings } from "./user-profile/attribute/AttributeGeneralSettings";
import { AttributePermission } from "./user-profile/attribute/AttributePermission";
import { AttributeValidations } from "./user-profile/attribute/AttributeValidations";
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
import "./realm-settings-section.css";
type IndexedAnnotations = {
key: string;
@ -56,6 +49,7 @@ type UserProfileAttributeFormFields = Omit<
UserProfileAttribute,
"validations" | "annotations"
> &
Translations &
Attribute &
Permission & {
validations: IndexedValidations[];
@ -93,41 +87,21 @@ type PermissionEdit = [
export const USERNAME_EMAIL = ["username", "email"];
const CreateAttributeFormContent = ({
onHandlingTranslationsData,
onHandlingGeneratedDisplayName,
save,
}: {
save: (profileConfig: UserProfileConfig) => void;
onHandlingTranslationsData: (translationsData: Translations) => void;
onHandlingGeneratedDisplayName: (generatedDisplayName: string) => void;
}) => {
const { t } = useTranslation();
const form = useFormContext();
const { realm, attributeName } = useParams<AttributeParams>();
const editMode = attributeName ? true : false;
const handleTranslationsData = (translationsData: Translations) => {
onHandlingTranslationsData(translationsData);
};
const handleGeneratedDisplayName = (generatedDisplayName: string) => {
onHandlingGeneratedDisplayName(generatedDisplayName);
};
return (
<UserProfileProvider>
<ScrollForm
label={t("jumpToSection")}
sections={[
{
title: t("generalSettings"),
panel: (
<AttributeGeneralSettings
onHandlingTranslationData={handleTranslationsData}
onHandlingGeneratedDisplayName={handleGeneratedDisplayName}
/>
),
},
{ title: t("generalSettings"), panel: <AttributeGeneralSettings /> },
{ title: t("permission"), panel: <AttributePermission /> },
{ title: t("validations"), panel: <AttributeValidations /> },
{ title: t("annotations"), panel: <AttributeAnnotations /> },
@ -158,79 +132,12 @@ const CreateAttributeFormContent = ({
export default function NewAttributeSettings() {
const { adminClient } = useAdminClient();
const { realm: realmName, attributeName } = useParams<AttributeParams>();
const { realmRepresentation: realm } = useRealm();
const form = useForm<UserProfileAttributeFormFields>();
const { t } = useTranslation();
const combinedLocales = useLocale();
const navigate = useNavigate();
const { addAlert, addError } = useAlerts();
const [config, setConfig] = useState<UserProfileConfig | null>(null);
const editMode = attributeName ? true : false;
const [translationsData, setTranslationsData] = useState<Translations>({
key: "",
translations: [],
});
const [generatedDisplayName, setGeneratedDisplayName] = useState<string>("");
useFetch(
async () => {
const translationsToSave: Translations[] = [];
await Promise.all(
combinedLocales.map(async (selectedLocale) => {
try {
const translations =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale,
});
const formData = form.getValues();
const formattedKey =
formData.displayName?.substring(
2,
formData.displayName.length - 1,
) || "";
const filteredTranslations: TranslationForm[] = Object.entries(
translations,
)
.filter(([key]) => key === formattedKey)
.map(([_, value]) => ({
locale: selectedLocale,
value,
}));
if (filteredTranslations.length > 0) {
translationsToSave.push({
key: formattedKey,
translations: filteredTranslations,
});
}
} catch (error) {
addError("errorSavingTranslations", error);
}
}),
);
return translationsToSave;
},
(translationsToSave) => {
if (translationsToSave && translationsToSave.length > 0) {
const allTranslations = translationsToSave.flatMap(
(translation) => translation.translations,
);
setTranslationsData({
key: translationsToSave[0].key,
translations: allTranslations,
});
form.setValue("translations", allTranslations);
}
},
[combinedLocales, realmName, form],
);
useFetch(
() => adminClient.users.getProfile(),
@ -278,31 +185,6 @@ export default function NewAttributeSettings() {
[],
);
const saveTranslations = async () => {
try {
const nonEmptyTranslations = translationsData.translations
.filter((translation) => translation.value.trim() !== "")
.map(async (translation) => {
try {
await adminClient.realms.addLocalization(
{
realm: realmName,
selectedLocale: translation.locale,
key: translationsData.key,
},
translation.value,
);
} catch (error) {
addError(t("errorSavingTranslations"), error);
}
});
await Promise.all(nonEmptyTranslations);
} catch (error) {
console.error(`Error saving translations: ${error}`);
}
};
const save = async ({
hasSelector,
hasRequiredScopes,
@ -358,7 +240,7 @@ export default function NewAttributeSettings() {
Object.assign(
{
name: formFields.name,
displayName: formFields.displayName! || generatedDisplayName,
displayName: formFields.displayName!,
required: formFields.isRequired ? formFields.required : undefined,
selector: formFields.selector,
permissions: formFields.permissions!,
@ -371,17 +253,6 @@ export default function NewAttributeSettings() {
),
] as UserProfileAttribute);
if (realm?.internationalizationEnabled) {
const hasNonEmptyTranslations = translationsData.translations.some(
(translation) => translation.value.trim() !== "",
);
if (!hasNonEmptyTranslations) {
addError("createAttributeError", t("translationError"));
return;
}
}
try {
const updatedAttributes = editMode ? patchAttributes() : addAttribute();
@ -391,8 +262,19 @@ export default function NewAttributeSettings() {
realm: realmName,
});
await saveTranslations();
i18n.reloadResources();
if (formFields.translation) {
try {
await saveTranslations({
adminClient,
realmName,
translationsData: {
translation: formFields.translation,
},
});
} catch (error) {
addError(t("errorSavingTranslations"), error);
}
}
navigate(toUserProfile({ realm: realmName, tab: "attributes" }));
addAlert(t("createAttributeSuccess"), AlertVariant.success);
@ -408,11 +290,7 @@ export default function NewAttributeSettings() {
subKey={editMode ? "" : t("createAttributeSubTitle")}
/>
<PageSection variant="light">
<CreateAttributeFormContent
save={() => form.handleSubmit(save)()}
onHandlingTranslationsData={setTranslationsData}
onHandlingGeneratedDisplayName={setGeneratedDisplayName}
/>
<CreateAttributeFormContent save={() => form.handleSubmit(save)()} />
</PageSection>
</FormProvider>
);

View File

@ -1,50 +1,29 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import type { UserProfileGroup } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import {
HelpItem,
TextControl,
useAlerts,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import { HelpItem, TextControl, useAlerts } from "@keycloak/keycloak-ui-shared";
import {
ActionGroup,
Alert,
Button,
FormGroup,
Grid,
GridItem,
PageSection,
Text,
TextContent,
TextInput,
} from "@patternfly/react-core";
import { useEffect, useMemo, useState } from "react";
import {
FormProvider,
SubmitHandler,
useForm,
useWatch,
} from "react-hook-form";
import { useEffect, useMemo } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useAdminClient } from "../../admin-client";
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";
import { ViewHeader } from "../../components/view-header/ViewHeader";
import { useRealm } from "../../context/realm-context/RealmContext";
import { i18n } from "../../i18n/i18n";
import useLocale from "../../utils/useLocale";
import useToggle from "../../utils/useToggle";
import "../realm-settings-section.css";
import type { EditAttributesGroupParams } from "../routes/EditAttributesGroup";
import { toUserProfile } from "../routes/UserProfile";
import { useUserProfile } from "./UserProfileContext";
import {
AddTranslationsDialog,
TranslationsType,
} from "./attribute/AddTranslationsDialog";
import { GlobeRouteIcon } from "@patternfly/react-icons";
import { useAdminClient } from "../../admin-client";
import { saveTranslations, Translations } from "./attribute/TranslatableField";
import { TranslatableField } from "./attribute/TranslatableField";
function parseAnnotations(input: Record<string, unknown>): KeyValueType[] {
return Object.entries(input).reduce((p, [key, value]) => {
@ -64,25 +43,17 @@ function transformAnnotations(input: KeyValueType[]): Record<string, unknown> {
);
}
type FormFields = Required<Omit<UserProfileGroup, "annotations">> & {
annotations: KeyValueType[];
};
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
type FormFields = Required<Omit<UserProfileGroup, "annotations">> &
Translations & {
annotations: KeyValueType[];
};
const defaultValues: FormFields = {
annotations: [],
displayDescription: "",
displayHeader: "",
name: "",
translation: { key: [] },
};
export default function AttributesGroupForm() {
@ -91,34 +62,10 @@ export default function AttributesGroupForm() {
const { realm: realmName, realmRepresentation: realm } = useRealm();
const { config, save } = useUserProfile();
const navigate = useNavigate();
const combinedLocales = useLocale();
const params = useParams<EditAttributesGroupParams>();
const form = useForm<FormFields>({ defaultValues });
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<TranslationsType>();
const [translationsData, setTranslationsData] = useState({
displayHeader: {
key: "",
translations: [] as TranslationForm[],
},
displayDescription: {
key: "",
translations: [] as TranslationForm[],
},
});
const matchingGroup = useMemo(
() => config?.groups?.find(({ name }) => name === params.name),
@ -137,156 +84,6 @@ export default function AttributesGroupForm() {
form.reset({ ...defaultValues, ...matchingGroup, annotations });
}, [matchingGroup, form]);
useEffect(() => {
form.setValue(
"displayHeader",
matchingGroup?.displayHeader || generatedAttributesGroupDisplayName || "",
);
form.setValue(
"displayDescription",
matchingGroup?.displayDescription ||
generatedAttributesGroupDisplayDescription ||
"",
);
}, [
generatedAttributesGroupDisplayName,
generatedAttributesGroupDisplayDescription,
matchingGroup,
form,
]);
useFetch(
async () => {
const translationsToSaveDisplayHeader: Translations[] = [];
const translationsToSaveDisplayDescription: Translations[] = [];
await Promise.all(
combinedLocales.map(async (locale: string) => {
try {
const translations =
await adminClient.realms.getRealmLocalizationTexts({
realm: realmName,
selectedLocale: locale,
});
const formData = form.getValues();
const extractKey = (value: string | undefined) => {
const match = value?.match(/\$\{(.*?)\}/);
return match ? match[1] : "";
};
const displayHeaderKey = extractKey(formData.displayHeader) || "";
const displayDescriptionKey =
extractKey(formData.displayDescription) || "";
const headerTranslation = translations[displayHeaderKey] || "";
const descriptionTranslation =
translations[displayDescriptionKey] || "";
if (headerTranslation) {
translationsToSaveDisplayHeader.push({
key: displayHeaderKey,
translations: [{ locale, value: headerTranslation }],
});
}
if (descriptionTranslation) {
translationsToSaveDisplayDescription.push({
key: displayDescriptionKey,
translations: [{ locale, value: descriptionTranslation }],
});
}
} catch (error) {
console.error(`Error fetching translations for ${locale}:`, error);
}
}),
);
const translationsDataNew = {
displayHeader: {
key:
translationsToSaveDisplayHeader.length > 0
? translationsToSaveDisplayHeader[0].key
: "",
translations: translationsToSaveDisplayHeader.flatMap(
(data) => data.translations,
),
},
displayDescription: {
key:
translationsToSaveDisplayDescription.length > 0
? translationsToSaveDisplayDescription[0].key
: "",
translations: translationsToSaveDisplayDescription.flatMap(
(data) => data.translations,
),
},
};
setTranslationsData(translationsDataNew);
},
() => {},
[combinedLocales, realmName, form],
);
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 &&
translationsData.displayHeader.translations.length > 0
) {
for (const translation of translationsData.displayHeader.translations) {
if (translation.locale && translation.value) {
await addLocalization(
translationsData.displayHeader.key,
translation.locale,
translation.value,
);
}
}
}
if (
translationsData &&
translationsData.displayDescription.translations.length > 0
) {
for (const translation of translationsData.displayDescription
.translations) {
if (translation.locale && translation.value) {
await addLocalization(
translationsData.displayDescription.key,
translation.locale,
translation.value,
);
}
}
}
} catch (error) {
console.error(`Error while processing translations: ${error}`);
}
};
const onSubmit: SubmitHandler<FormFields> = async (values) => {
if (!config) {
return;
@ -294,8 +91,9 @@ export default function AttributesGroupForm() {
const groups = [...(config.groups ?? [])];
const updateAt = matchingGroup ? groups.indexOf(matchingGroup) : -1;
const { translation, ...groupValues } = values;
const updatedGroup: UserProfileGroup = {
...values,
...groupValues,
annotations: transformAnnotations(values.annotations),
};
@ -305,303 +103,100 @@ 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) {
await saveTranslations();
i18n.reloadResources();
if (realm?.internationalizationEnabled) {
try {
await saveTranslations({
adminClient,
realmName,
translationsData: { translation },
});
} catch (error) {
addError(t("errorSavingTranslations"), error);
}
}
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<HTMLInputElement>,
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 && (
<AddTranslationsDialog
translationKey={
type === "displayHeader"
? groupDisplayNameKey
: groupDisplayDescriptionKey
}
type={
type === "displayHeader" ? "displayHeader" : "displayDescription"
}
translations={
type === "displayHeader"
? translationsData.displayHeader
: translationsData.displayDescription
}
onTranslationsAdded={
type === "displayHeader"
? handleHeaderTranslationsAdded
: handleDescriptionTranslationsAdded
}
toggleDialog={handleToggleDialog}
onCancel={() => {
toggleModal();
}}
/>
)}
<FormProvider {...form}>
<ViewHeader
titleKey={matchingGroup ? "editGroupText" : "createGroupText"}
divider
/>
<PageSection variant="light" onSubmit={form.handleSubmit(onSubmit)}>
<FormAccess isHorizontal role="manage-realm">
<FormProvider {...form}>
<TextControl
name="name"
label={t("nameField")}
labelIcon={t("nameHintHelp")}
isDisabled={!!matchingGroup || editMode}
rules={{
required: t("required"),
onChange: (event) => {
handleAttributesGroupNameChange(event, event.target.value);
},
}}
<TextControl
name="name"
label={t("nameField")}
labelIcon={t("nameHintHelp")}
isDisabled={!!matchingGroup || editMode}
rules={{
required: t("required"),
}}
/>
<FormGroup
label={t("displayHeader")}
labelIcon={
<HelpItem
helpText={t("displayHeaderHintHelp")}
fieldLabelId="displayHeader"
/>
}
fieldId="kc-attributes-group-display-header"
>
<TranslatableField
fieldName="displayHeader"
attributeName="name"
prefix="profile.attribute-group"
/>
{!!matchingGroup && (
<input type="hidden" {...form.register("name")} />
)}
<FormGroup
label={t("displayHeaderField")}
labelIcon={
<HelpItem
helpText={t("displayHeaderHintHelp")}
fieldLabelId="displayHeaderField"
</FormGroup>
<FormGroup
label={t("displayDescription")}
labelIcon={
<HelpItem
helpText={t("displayDescriptionHintHelp")}
fieldLabelId="displayDescription"
/>
}
fieldId="kc-attributes-group-display-description"
>
<TranslatableField
fieldName="displayDescription"
attributeName="name"
prefix="profile.attribute-group-description"
/>
</FormGroup>
<TextContent>
<Text component="h2">{t("annotationsText")}</Text>
</TextContent>
<FormGroup label={t("annotationsText")} fieldId="kc-annotations">
<KeyValueInput label={t("annotationsText")} name="annotations" />
</FormGroup>
<ActionGroup>
<Button variant="primary" type="submit" data-testid="saveGroupBtn">
{t("save")}
</Button>
<Button
variant="link"
component={(props) => (
<Link
{...props}
to={toUserProfile({
realm: realmName,
tab: "attributes-group",
})}
/>
}
fieldId="kc-attributes-group-display-header"
)}
>
<Grid hasGutter>
<GridItem span={realm?.internationalizationEnabled ? 11 : 12}>
<TextInput
id="kc-attributes-group-display-header"
data-testid="attributes-group-display-header"
isDisabled={
(realm?.internationalizationEnabled &&
newAttributesGroupName !== "") ||
(editMode && attributesGroupDisplayPatternMatch)
}
value={
editMode
? attributesGroupDisplayName
: realm?.internationalizationEnabled
? generatedAttributesGroupDisplayName
: undefined
}
{...form.register("displayHeader")}
/>
{generatedAttributesGroupDisplayName && (
<Alert
className="pf-v5-u-mt-sm"
variant="info"
isInline
isPlain
title={t("addAttributesGroupTranslationInfo")}
/>
)}
</GridItem>
{realm?.internationalizationEnabled && (
<GridItem span={1}>
<Button
variant="link"
className="pf-m-plain"
data-testid="addAttributeDisplayNameTranslationBtn"
aria-label={t("addAttributeDisplayNameTranslation")}
isDisabled={!newAttributesGroupName && !editMode}
onClick={() => {
setType("displayHeader");
toggleModal();
}}
icon={<GlobeRouteIcon />}
/>
</GridItem>
)}
</Grid>
</FormGroup>
<FormGroup
label={t("displayDescriptionField")}
labelIcon={
<HelpItem
helpText={t("displayDescriptionHintHelp")}
fieldLabelId="displayDescriptionField"
/>
}
fieldId="kc-attributes-group-display-description"
>
<Grid hasGutter>
<GridItem span={realm?.internationalizationEnabled ? 11 : 12}>
<TextInput
id="kc-attributes-group-display-description"
data-testid="attributes-group-display-description"
isDisabled={
(realm?.internationalizationEnabled &&
newAttributesGroupName !== "") ||
(editMode && attributesGroupDisplayPatternMatch)
}
value={
editMode
? attributesGroupDisplayDescription
: realm?.internationalizationEnabled
? generatedAttributesGroupDisplayDescription
: undefined
}
{...form.register("displayDescription")}
/>
{generatedAttributesGroupDisplayDescription && (
<Alert
className="pf-v5-u-mt-sm"
variant="info"
isInline
isPlain
title={t("addAttributesGroupTranslationInfo")}
/>
)}
</GridItem>
{realm?.internationalizationEnabled && (
<GridItem span={1}>
<Button
variant="link"
className="pf-m-plain"
data-testid="addAttributeDisplayDescriptionTranslationBtn"
aria-label={t(
"addAttributeDisplayDescriptionTranslation",
)}
isDisabled={!newAttributesGroupName && !editMode}
onClick={() => {
setType("displayDescription");
toggleModal();
}}
icon={<GlobeRouteIcon />}
/>
</GridItem>
)}
</Grid>
</FormGroup>
<TextContent>
<Text component="h2">{t("annotationsText")}</Text>
</TextContent>
<FormGroup label={t("annotationsText")} fieldId="kc-annotations">
<KeyValueInput label={t("annotationsText")} name="annotations" />
</FormGroup>
<ActionGroup>
<Button
variant="primary"
type="submit"
data-testid="saveGroupBtn"
>
{t("save")}
</Button>
<Button
variant="link"
component={(props) => (
<Link
{...props}
to={toUserProfile({
realm: realmName,
tab: "attributes-group",
})}
/>
)}
>
{t("cancel")}
</Button>
</ActionGroup>
</FormProvider>
{t("cancel")}
</Button>
</ActionGroup>
</FormAccess>
</PageSection>
</>
</FormProvider>
);
}

View File

@ -1,6 +1,6 @@
import {
ListEmptyState,
PaginatingTableToolbar,
TextControl,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import {
@ -14,51 +14,31 @@ import {
ModalVariant,
Text,
TextContent,
TextInput,
TextVariants,
} from "@patternfly/react-core";
import { SearchIcon } from "@patternfly/react-icons";
import { Table, Tbody, Td, Th, Thead, Tr } from "@patternfly/react-table";
import { useEffect, useMemo, useState } from "react";
import { FormProvider, useForm, useWatch } from "react-hook-form";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../../admin-client";
import { ListEmptyState } from "@keycloak/keycloak-ui-shared";
import { useRealm } from "../../../context/realm-context/RealmContext";
import { useWhoAmI } from "../../../context/whoami/WhoAmI";
import { localeToDisplayName } from "../../../util";
import { beerify, localeToDisplayName } from "../../../util";
import useLocale from "../../../utils/useLocale";
import { Translation, TranslationForm } from "./TranslatableField";
export type TranslationsType =
| "displayName"
| "displayHeader"
| "displayDescription";
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
export type AddTranslationsDialogProps = {
type AddTranslationsDialogProps = {
translationKey: string;
translations: Translations;
type: TranslationsType;
onCancel: () => void;
fieldName: string;
toggleDialog: () => void;
onTranslationsAdded: (translations: Translations) => void;
};
export const AddTranslationsDialog = ({
translationKey,
translations,
type,
onCancel,
fieldName,
toggleDialog,
onTranslationsAdded,
}: AddTranslationsDialogProps) => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
@ -68,39 +48,33 @@ export const AddTranslationsDialog = ({
const [max, setMax] = useState(10);
const [first, setFirst] = useState(0);
const [filter, setFilter] = useState("");
const [defaultTranslations, setDefaultTranslations] = useState<{
[key: string]: string;
}>({});
const form = useForm<{
key: string;
translations: TranslationForm[];
}>({
mode: "onChange",
});
const [translations, setTranslations] = useState<TranslationForm[]>([]);
const prefix = `translation.${beerify(translationKey)}`;
const {
getValues,
handleSubmit,
register,
setValue,
getValues,
formState: { isValid },
} = form;
} = useFormContext();
const defaultLocales = useMemo(() => {
return realm?.defaultLocale!.length ? [realm.defaultLocale] : [];
}, [realm]);
const filteredLocales = useMemo(() => {
return combinedLocales.filter((locale) =>
localeToDisplayName(locale, whoAmI.getLocale())!
.toLowerCase()
.includes(filter.toLowerCase()),
);
}, [combinedLocales, filter, whoAmI]);
const setupForm = (translation: Translation) => {
translation[translationKey].forEach((translation, rowIndex) => {
const valueKey = `${prefix}.${rowIndex}.value`;
setValue(`${prefix}.${rowIndex}.locale`, translation.locale || "");
setValue(valueKey, getValues(valueKey) || translation.value);
});
};
useFetch(
async () => {
const selectedLocales = combinedLocales.map((locale) => locale);
const selectedLocales = combinedLocales
.filter((l) =>
localeToDisplayName(l, whoAmI.getLocale())
?.toLocaleLowerCase(realm?.defaultLocale)
?.includes(filter.toLocaleLowerCase(realm?.defaultLocale)),
)
.slice(first, first + max + 1);
const results = await Promise.all(
selectedLocales.map((selectedLocale) =>
@ -111,83 +85,18 @@ export const AddTranslationsDialog = ({
),
);
const translations = results.map((result, index) => {
const locale = selectedLocales[index];
const value = result[translationKey];
return {
key: translationKey,
translations: [{ locale, value }],
};
});
const defaultValuesMap = translations.reduce((acc, translation) => {
const locale = translation.translations[0].locale;
const value = translation.translations[0].value;
return { ...acc, [locale]: value };
}, {});
return defaultValuesMap;
return results.map((result, index) => ({
locale: selectedLocales[index],
value: result[translationKey],
}));
},
(fetchedData) => {
setDefaultTranslations((prevTranslations) => {
if (prevTranslations !== fetchedData) {
return fetchedData;
}
return prevTranslations;
});
setTranslations(fetchedData);
setupForm({ [translationKey]: fetchedData });
},
[combinedLocales, translationKey, realmName],
[combinedLocales, first, max, filter],
);
useEffect(() => {
combinedLocales.forEach((locale, rowIndex) => {
setValue(`translations.${rowIndex}.locale`, locale);
const translationExists =
translations.translations[rowIndex] !== undefined;
setValue(
`translations.${rowIndex}.value`,
translationExists
? translations.translations[rowIndex]?.value
: defaultTranslations[locale] || "",
);
});
setValue("key", translationKey);
}, [
combinedLocales,
defaultTranslations,
translationKey,
setValue,
translations,
]);
const handleOk = () => {
const formData = getValues();
const updatedTranslations = formData.translations.map((translation) => {
if (translation.locale === filter) {
return {
...translation,
value:
formData.translations.find((t) => t.locale === filter)?.value ?? "",
};
}
return translation;
});
onTranslationsAdded({
key: formData.key,
translations: updatedTranslations,
});
toggleDialog();
};
const isRequiredTranslation = useWatch({
control: form.control,
name: "translations.0.value",
});
return (
<Modal
variant={ModalVariant.medium}
@ -199,9 +108,9 @@ export const AddTranslationsDialog = ({
key="ok"
data-testid="okTranslationBtn"
variant="primary"
type="submit"
form="add-translation"
isDisabled={!isValid || !isRequiredTranslation}
isDisabled={!isValid}
onClick={toggleDialog}
>
{t("addTranslationDialogOkBtn")}
</Button>,
@ -209,7 +118,10 @@ export const AddTranslationsDialog = ({
key="cancel"
data-testid="cancelTranslationBtn"
variant="link"
onClick={onCancel}
onClick={() => {
setupForm({ [translationKey]: translations });
toggleDialog();
}}
>
{t("cancel")}
</Button>,
@ -222,141 +134,108 @@ export const AddTranslationsDialog = ({
<FlexItem>
<TextContent>
<Text component={TextVariants.p}>
{type !== "displayHeader"
? t("addTranslationsModalSubTitleDescription")
: t("addTranslationsModalSubTitle")}{" "}
{t("addTranslationsModalSubTitle", { fieldName })}
<strong>{t("addTranslationsModalSubTitleBolded")}</strong>
</Text>
</TextContent>
</FlexItem>
<FlexItem>
<FormProvider {...form}>
<Form
id="add-translation"
data-testid="addTranslationForm"
onSubmit={handleSubmit(handleOk)}
>
<TextControl
name="key"
<Form id="add-translation" data-testid="addTranslationForm">
<FormGroup label={t("translationKey")} fieldId="translationKey">
<TextInput
id="translationKey"
label={t("translationKey")}
className="pf-v5-u-mt-md"
data-testid="translation-key"
isDisabled
value={translationKey}
/>
<FlexItem>
<TextContent>
<Text
className="pf-v5-u-font-size-sm pf-v5-u-font-weight-bold"
component={TextVariants.p}
>
{t("translationsTableHeading")}
</Text>
</TextContent>
<PaginatingTableToolbar
count={combinedLocales.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
inputGroupName={"search"}
inputGroupOnEnter={(search) => {
setFilter(search);
setFirst(0);
setMax(10);
}}
inputGroupPlaceholder={t("searchForLanguage")}
</FormGroup>
<FlexItem>
<TextContent>
<Text
className="pf-v5-u-font-size-sm pf-v5-u-font-weight-bold"
component={TextVariants.p}
>
{filteredLocales.length === 0 && !filter && (
<ListEmptyState
hasIcon
message={t("noLanguages")}
instructions={t("noLanguagesInstructions")}
/>
)}
{filteredLocales.length === 0 && filter && (
<ListEmptyState
hasIcon
icon={SearchIcon}
isSearchVariant
message={t("noSearchResults")}
instructions={t("noLanguagesSearchResultsInstructions")}
/>
)}
{filteredLocales.length !== 0 && (
<Table
aria-label={t("addTranslationsDialogRowsTable")}
data-testid="add-translations-dialog-rows-table"
>
<Thead>
<Tr>
<Th className="pf-v5-u-py-lg">
{t("supportedLanguagesTableColumnName")}
</Th>
<Th className="pf-v5-u-py-lg">
{t("translationTableColumnName")}
</Th>
<Th aria-hidden="true" />
{t("translationsTableHeading")}
</Text>
</TextContent>
<PaginatingTableToolbar
count={translations.length}
first={first}
max={max}
onNextClick={setFirst}
onPreviousClick={setFirst}
onPerPageSelect={(first, max) => {
setFirst(first);
setMax(max);
}}
inputGroupName={"search"}
inputGroupOnEnter={(search) => {
setFilter(search);
setFirst(0);
setMax(10);
}}
inputGroupPlaceholder={t("searchForLanguage")}
>
{translations.length === 0 && filter && (
<ListEmptyState
hasIcon
icon={SearchIcon}
isSearchVariant
message={t("noSearchResults")}
instructions={t("noLanguagesSearchResultsInstructions")}
/>
)}
{translations.length !== 0 && (
<Table
aria-label={t("addTranslationsDialogRowsTable")}
data-testid="add-translations-dialog-rows-table"
>
<Thead>
<Tr>
<Th className="pf-v5-u-py-lg">
{t("supportedLanguagesTableColumnName")}
</Th>
<Th className="pf-v5-u-py-lg">
{t("translationTableColumnName")}
</Th>
</Tr>
</Thead>
<Tbody>
{translations.slice(0, max).map((translation, index) => (
<Tr key={index}>
<Td dataLabel={t("supportedLanguage")}>
{localeToDisplayName(
translation.locale,
whoAmI.getLocale(),
)}
{translation.locale === realm?.defaultLocale && (
<Label className="pf-v5-u-ml-xs" color="blue">
{t("defaultLanguage")}
</Label>
)}
</Td>
<Td>
<TextInput
id={`${prefix}.${index}.value`}
data-testid={`translation-value-${index}`}
{...register(`${prefix}.${index}.value`, {
required: {
value:
translation.locale === realm?.defaultLocale,
message: t("required"),
},
})}
/>
</Td>
</Tr>
</Thead>
<Tbody>
{filteredLocales.map((locale, index) => {
const rowIndex = combinedLocales.findIndex(
(combinedLocale) => combinedLocale === locale,
);
return (
<Tr key={index}>
<Td
className="pf-m-sm pf-v5-u-px-sm"
dataLabel={t("supportedLanguage")}
>
<FormGroup fieldId="kc-supportedLanguage">
{localeToDisplayName(
locale,
whoAmI.getLocale(),
)}
{locale === defaultLocales.toString() && (
<Label
className="pf-v5-u-ml-xs"
color="blue"
>
{t("defaultLanguage")}
</Label>
)}
</FormGroup>
</Td>
<Td>
{locale === defaultLocales.toString() && (
<TextControl
name={`translations.${rowIndex}.value`}
label={t("translationValue")}
data-testid={`translation-value-${rowIndex}`}
rules={{
required: t("required"),
}}
/>
)}
{locale !== defaultLocales.toString() && (
<TextControl
name={`translations.${rowIndex}.value`}
label={t("translationValue")}
data-testid={`translation-value-${rowIndex}`}
/>
)}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
</PaginatingTableToolbar>
</FlexItem>
</Form>
</FormProvider>
))}
</Tbody>
</Table>
)}
</PaginatingTableToolbar>
</FlexItem>
</Form>
</FlexItem>
</Flex>
</Modal>

View File

@ -1,28 +1,23 @@
import type ClientScopeRepresentation from "@keycloak/keycloak-admin-client/lib/defs/clientScopeRepresentation";
import type { UserProfileConfig } from "@keycloak/keycloak-admin-client/lib/defs/userProfileMetadata";
import {
FormErrorText,
HelpItem,
KeycloakSelect,
KeycloakSpinner,
SelectControl,
SelectVariant,
TextControl,
useFetch,
} from "@keycloak/keycloak-ui-shared";
import {
Alert,
Button,
Divider,
FormGroup,
Grid,
GridItem,
Radio,
SelectOption,
Switch,
TextInput,
} from "@patternfly/react-core";
import { GlobeRouteIcon } from "@patternfly/react-icons";
import { isEqual } from "lodash-es";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Controller,
FormProvider,
@ -32,17 +27,11 @@ import {
import { useTranslation } from "react-i18next";
import { useAdminClient } from "../../../admin-client";
import { FormAccess } from "../../../components/form/FormAccess";
import { KeycloakSpinner } from "@keycloak/keycloak-ui-shared";
import { DefaultSwitchControl } from "../../../components/SwitchControl";
import { useRealm } from "../../../context/realm-context/RealmContext";
import { useParams } from "../../../utils/useParams";
import useToggle from "../../../utils/useToggle";
import { USERNAME_EMAIL } from "../../NewAttributeSettings";
import { AttributeParams } from "../../routes/Attribute";
import {
AddTranslationsDialog,
TranslationsType,
} from "./AddTranslationsDialog";
import { TranslatableField } from "./TranslatableField";
import "../../realm-settings-section.css";
@ -52,57 +41,17 @@ const REQUIRED_FOR = [
{ label: "requiredForLabel.admins", value: ["admin"] },
] as const;
type TranslationForm = {
locale: string;
value: string;
};
type Translations = {
key: string;
translations: TranslationForm[];
};
export type AttributeGeneralSettingsProps = {
onHandlingTranslationData: (data: Translations) => void;
onHandlingGeneratedDisplayName: (displayName: string) => void;
};
export const AttributeGeneralSettings = ({
onHandlingTranslationData,
onHandlingGeneratedDisplayName,
}: AttributeGeneralSettingsProps) => {
export const AttributeGeneralSettings = () => {
const { adminClient } = useAdminClient();
const { t } = useTranslation();
const { realmRepresentation: realm } = useRealm();
const form = useFormContext();
const [clientScopes, setClientScopes] =
useState<ClientScopeRepresentation[]>();
const [config, setConfig] = useState<UserProfileConfig>();
const [selectEnabledWhenOpen, setSelectEnabledWhenOpen] = useState(false);
const [selectRequiredForOpen, setSelectRequiredForOpen] = useState(false);
const [addTranslationsModalOpen, toggleModal] = useToggle();
const { attributeName } = useParams<AttributeParams>();
const editMode = attributeName ? true : false;
const [newAttributeName, setNewAttributeName] = useState("");
const [generatedDisplayName, setGeneratedDisplayName] = useState("");
const [type, setType] = useState<TranslationsType>();
const [translationsData, setTranslationsData] = useState<Translations>({
key: "",
translations: [],
});
const displayNameRegex = /\$\{([^}]+)\}/;
const handleAttributeNameChange = (
_event: React.FormEvent<HTMLInputElement>,
value: string,
) => {
setNewAttributeName(value);
const newDisplayName =
value !== "" && realm?.internationalizationEnabled
? "${profile.attributes." + `${value}}`
: "";
setGeneratedDisplayName(newDisplayName);
};
const hasSelector = useWatch({
control: form.control,
@ -120,29 +69,9 @@ export const AttributeGeneralSettings = ({
defaultValue: false,
});
const attributeDisplayName = useWatch({
control: form.control,
name: "displayName",
});
const displayNamePatternMatch = displayNameRegex.test(attributeDisplayName);
useFetch(() => adminClient.clientScopes.find(), setClientScopes, []);
useFetch(() => adminClient.users.getProfile(), setConfig, []);
const handleTranslationsData = (translationsData: Translations) => {
onHandlingTranslationData(translationsData);
};
const handleGeneratedDisplayName = (displayName: string) => {
onHandlingGeneratedDisplayName(displayName);
};
useEffect(() => {
handleTranslationsData(translationsData);
handleGeneratedDisplayName(generatedDisplayName);
}, [translationsData, generatedDisplayName]);
if (!clientScopes) {
return <KeycloakSpinner />;
}
@ -155,379 +84,286 @@ export const AttributeGeneralSettings = ({
form.setValue("hasRequiredScopes", hasRequiredScopes);
}
const handleTranslationsAdded = (translationsData: Translations) => {
setTranslationsData(translationsData);
};
const handleToggleDialog = () => {
toggleModal();
handleTranslationsData(translationsData);
handleGeneratedDisplayName(generatedDisplayName);
};
const formattedAttributeDisplayName = attributeDisplayName?.substring(
2,
attributeDisplayName.length - 1,
);
return (
<>
{addTranslationsModalOpen && (
<AddTranslationsDialog
translationKey={
editMode
? formattedAttributeDisplayName
: `profile.attributes.${newAttributeName}`
}
translations={translationsData}
type={type ?? "displayName"}
onTranslationsAdded={handleTranslationsAdded}
toggleDialog={handleToggleDialog}
onCancel={() => {
toggleModal();
<FormProvider {...form}>
<FormAccess role="manage-realm" isHorizontal>
<TextControl
name="name"
label={t("attributeName")}
labelIcon={t("upAttributeNameHelp")}
isDisabled={editMode}
rules={{
required: t("validateAttributeName"),
}}
/>
)}
<FormAccess role="manage-realm" isHorizontal>
<FormProvider {...form}>
<FormGroup
label={t("attributeName")}
labelIcon={
<HelpItem
helpText={t("upAttributeNameHelp")}
fieldLabelId="attributeName"
/>
}
fieldId="kc-attribute-name"
isRequired
>
<TextInput
isRequired
id="kc-attribute-name"
defaultValue=""
data-testid="attribute-name"
isDisabled={editMode}
validated={form.formState.errors.name ? "error" : "default"}
{...form.register("name", { required: true })}
onChange={handleAttributeNameChange}
<FormGroup
label={t("attributeDisplayName")}
labelIcon={
<HelpItem
helpText={t("attributeDisplayNameHelp")}
fieldLabelId="attributeDisplayName"
/>
{form.formState.errors.name && (
<FormErrorText message={t("validateAttributeName")} />
)}
</FormGroup>
<FormGroup
label={t("attributeDisplayName")}
labelIcon={
<HelpItem
helpText={t("attributeDisplayNameHelp")}
fieldLabelId="attributeDisplayName"
}
fieldId="kc-attribute-displayName"
>
<TranslatableField
attributeName="name"
prefix="profile.attributes"
fieldName="displayName"
/>
</FormGroup>
<DefaultSwitchControl
name="multivalued"
label={t("multivalued")}
labelIcon={t("multivaluedHelp")}
/>
<SelectControl
name="group"
label={t("attributeGroup")}
labelIcon={t("attributeGroupHelp")}
controller={{
defaultValue: "",
}}
options={[
{ key: "", value: t("none") },
...(config?.groups?.map((g) => ({
key: g.name!,
value: g.name!,
})) || []),
]}
/>
{!USERNAME_EMAIL.includes(attributeName) && (
<>
<Divider />
<FormGroup
label={t("enabledWhen")}
labelIcon={
<HelpItem
helpText={t("enabledWhenTooltip")}
fieldLabelId="enabled-when"
/>
}
fieldId="enabledWhen"
hasNoPaddingTop
>
<Radio
id="always"
data-testid="always"
isChecked={!hasSelector}
name="enabledWhen"
label={t("always")}
onChange={() => setHasSelector(false)}
className="pf-v5-u-mb-md"
/>
}
fieldId="kc-attribute-display-name"
>
<Grid hasGutter>
<GridItem span={realm?.internationalizationEnabled ? 11 : 12}>
<TextInput
id="kc-attribute-display-name"
data-testid="attribute-display-name"
isDisabled={
(realm?.internationalizationEnabled &&
newAttributeName !== "") ||
(editMode && displayNamePatternMatch)
}
value={
editMode
? attributeDisplayName
: realm?.internationalizationEnabled
? generatedDisplayName
: undefined
}
{...form.register("displayName")}
/>
{generatedDisplayName && (
<Alert
className="pf-v5-u-mt-sm"
variant="info"
isInline
isPlain
title={t("addAttributeTranslationInfo")}
/>
)}
</GridItem>
{realm?.internationalizationEnabled && (
<GridItem span={1}>
<Button
variant="link"
className="pf-m-plain kc-attribute-display-name-iconBtn"
data-testid="addAttributeTranslationBtn"
aria-label={t("addAttributeTranslationBtn")}
isDisabled={!newAttributeName && !editMode}
onClick={() => {
setType("displayName");
toggleModal();
}}
icon={<GlobeRouteIcon />}
/>
</GridItem>
)}
</Grid>
</FormGroup>
<DefaultSwitchControl
name="multivalued"
label={t("multivalued")}
labelIcon={t("multivaluedHelp")}
/>
<SelectControl
name="group"
label={t("attributeGroup")}
labelIcon={t("attributeGroupHelp")}
controller={{
defaultValue: "",
}}
options={[
{ key: "", value: t("none") },
...(config?.groups?.map((g) => ({
key: g.name!,
value: g.name!,
})) || []),
]}
/>
{!USERNAME_EMAIL.includes(attributeName) && (
<>
<Divider />
<FormGroup
label={t("enabledWhen")}
labelIcon={
<HelpItem
helpText={t("enabledWhenTooltip")}
fieldLabelId="enabled-when"
/>
}
fieldId="enabledWhen"
hasNoPaddingTop
>
<Radio
id="always"
data-testid="always"
isChecked={!hasSelector}
name="enabledWhen"
label={t("always")}
onChange={() => setHasSelector(false)}
className="pf-v5-u-mb-md"
/>
<Radio
id="scopesAsRequested"
data-testid="scopesAsRequested"
isChecked={hasSelector}
name="enabledWhen"
label={t("scopesAsRequested")}
onChange={() => setHasSelector(true)}
className="pf-v5-u-mb-md"
<Radio
id="scopesAsRequested"
data-testid="scopesAsRequested"
isChecked={hasSelector}
name="enabledWhen"
label={t("scopesAsRequested")}
onChange={() => setHasSelector(true)}
className="pf-v5-u-mb-md"
/>
</FormGroup>
{hasSelector && (
<FormGroup fieldId="kc-scope-enabled-when">
<Controller
name="selector.scopes"
control={form.control}
defaultValue={[]}
render={({ field }) => (
<KeycloakSelect
data-testid="enabled-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
onToggle={(isOpen) => setSelectEnabledWhenOpen(isOpen)}
selections={field.value}
onSelect={(selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (field.value) {
changedValue = field.value.includes(option)
? field.value.filter(
(item: string) => item !== option,
)
: [...field.value, option];
} else {
changedValue = [option];
}
field.onChange(changedValue);
}}
onClear={() => {
field.onChange([]);
}}
isOpen={selectEnabledWhenOpen}
aria-labelledby={"scope"}
>
{clientScopes.map((option) => (
<SelectOption key={option.name} value={option.name}>
{option.name}
</SelectOption>
))}
</KeycloakSelect>
)}
/>
</FormGroup>
{hasSelector && (
<FormGroup fieldId="kc-scope-enabled-when">
)}
</>
)}
{attributeName !== "username" && (
<>
<Divider />
<FormGroup
label={t("required")}
labelIcon={
<HelpItem
helpText={t("requiredHelp")}
fieldLabelId="required"
/>
}
fieldId="kc-required"
hasNoPaddingTop
>
<Controller
name="isRequired"
data-testid="required"
defaultValue={false}
control={form.control}
render={({ field }) => (
<Switch
id={"kc-required"}
onChange={field.onChange}
isChecked={field.value}
label={t("on")}
labelOff={t("off")}
aria-label={t("required")}
/>
)}
/>
</FormGroup>
{required && (
<>
<FormGroup
label={t("requiredFor")}
fieldId="requiredFor"
hasNoPaddingTop
>
<Controller
name="selector.scopes"
name="required.roles"
data-testid="requiredFor"
defaultValue={REQUIRED_FOR[0].value}
control={form.control}
defaultValue={[]}
render={({ field }) => (
<KeycloakSelect
data-testid="enabled-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
onToggle={(isOpen) => setSelectEnabledWhenOpen(isOpen)}
selections={field.value}
onSelect={(selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (field.value) {
changedValue = field.value.includes(option)
? field.value.filter(
(item: string) => item !== option,
)
: [...field.value, option];
} else {
changedValue = [option];
}
field.onChange(changedValue);
}}
onClear={() => {
field.onChange([]);
}}
isOpen={selectEnabledWhenOpen}
aria-labelledby={"scope"}
>
{clientScopes.map((option) => (
<SelectOption key={option.name} value={option.name}>
{option.name}
</SelectOption>
<div className="kc-requiredFor">
{REQUIRED_FOR.map((option) => (
<Radio
id={option.label}
key={option.label}
data-testid={option.label}
isChecked={isEqual(field.value, option.value)}
name="roles"
onChange={() => {
field.onChange(option.value);
}}
label={t(option.label)}
className="kc-requiredFor-option"
/>
))}
</KeycloakSelect>
</div>
)}
/>
</FormGroup>
)}
</>
)}
{attributeName !== "username" && (
<>
<Divider />
<FormGroup
label={t("required")}
labelIcon={
<HelpItem
helpText={t("requiredHelp")}
fieldLabelId="required"
/>
}
fieldId="kc-required"
hasNoPaddingTop
>
<Controller
name="isRequired"
data-testid="required"
defaultValue={false}
control={form.control}
render={({ field }) => (
<Switch
id={"kc-required"}
onChange={field.onChange}
isChecked={field.value}
label={t("on")}
labelOff={t("off")}
aria-label={t("required")}
<FormGroup
label={t("requiredWhen")}
labelIcon={
<HelpItem
helpText={t("requiredWhenTooltip")}
fieldLabelId="required-when"
/>
)}
/>
</FormGroup>
{required && (
<>
<FormGroup
label={t("requiredFor")}
fieldId="requiredFor"
hasNoPaddingTop
>
}
fieldId="requiredWhen"
hasNoPaddingTop
>
<Radio
id="requiredAlways"
data-testid="requiredAlways"
isChecked={!hasRequiredScopes}
name="requiredWhen"
label={t("always")}
onChange={() => setHasRequiredScopes(false)}
className="pf-v5-u-mb-md"
/>
<Radio
id="requiredScopesAsRequested"
data-testid="requiredScopesAsRequested"
isChecked={hasRequiredScopes}
name="requiredWhen"
label={t("scopesAsRequested")}
onChange={() => setHasRequiredScopes(true)}
className="pf-v5-u-mb-md"
/>
</FormGroup>
{hasRequiredScopes && (
<FormGroup fieldId="kc-scope-required-when">
<Controller
name="required.roles"
data-testid="requiredFor"
defaultValue={REQUIRED_FOR[0].value}
name="required.scopes"
control={form.control}
defaultValue={[]}
render={({ field }) => (
<div className="kc-requiredFor">
{REQUIRED_FOR.map((option) => (
<Radio
id={option.label}
key={option.label}
data-testid={option.label}
isChecked={isEqual(field.value, option.value)}
name="roles"
onChange={() => {
field.onChange(option.value);
}}
label={t(option.label)}
className="kc-requiredFor-option"
/>
<KeycloakSelect
data-testid="required-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
onToggle={(isOpen) =>
setSelectRequiredForOpen(isOpen)
}
selections={field.value}
onSelect={(selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (field.value) {
changedValue = field.value.includes(option)
? field.value.filter(
(item: string) => item !== option,
)
: [...field.value, option];
} else {
changedValue = [option];
}
field.onChange(changedValue);
}}
onClear={() => {
field.onChange([]);
}}
isOpen={selectRequiredForOpen}
aria-labelledby={"scope"}
>
{clientScopes.map((option) => (
<SelectOption key={option.name} value={option.name}>
{option.name}
</SelectOption>
))}
</div>
</KeycloakSelect>
)}
/>
</FormGroup>
<FormGroup
label={t("requiredWhen")}
labelIcon={
<HelpItem
helpText={t("requiredWhenTooltip")}
fieldLabelId="required-when"
/>
}
fieldId="requiredWhen"
hasNoPaddingTop
>
<Radio
id="requiredAlways"
data-testid="requiredAlways"
isChecked={!hasRequiredScopes}
name="requiredWhen"
label={t("always")}
onChange={() => setHasRequiredScopes(false)}
className="pf-v5-u-mb-md"
/>
<Radio
id="requiredScopesAsRequested"
data-testid="requiredScopesAsRequested"
isChecked={hasRequiredScopes}
name="requiredWhen"
label={t("scopesAsRequested")}
onChange={() => setHasRequiredScopes(true)}
className="pf-v5-u-mb-md"
/>
</FormGroup>
{hasRequiredScopes && (
<FormGroup fieldId="kc-scope-required-when">
<Controller
name="required.scopes"
control={form.control}
defaultValue={[]}
render={({ field }) => (
<KeycloakSelect
data-testid="required-when-scope-field"
variant={SelectVariant.typeaheadMulti}
typeAheadAriaLabel="Select"
chipGroupProps={{
numChips: 3,
expandedText: t("hide"),
collapsedText: t("showRemaining"),
}}
onToggle={(isOpen) =>
setSelectRequiredForOpen(isOpen)
}
selections={field.value}
onSelect={(selectedValue) => {
const option = selectedValue.toString();
let changedValue = [""];
if (field.value) {
changedValue = field.value.includes(option)
? field.value.filter(
(item: string) => item !== option,
)
: [...field.value, option];
} else {
changedValue = [option];
}
field.onChange(changedValue);
}}
onClear={() => {
field.onChange([]);
}}
isOpen={selectRequiredForOpen}
aria-labelledby={"scope"}
>
{clientScopes.map((option) => (
<SelectOption
key={option.name}
value={option.name}
>
{option.name}
</SelectOption>
))}
</KeycloakSelect>
)}
/>
</FormGroup>
)}
</>
)}
</>
)}
</FormProvider>
)}
</>
)}
</>
)}
</FormAccess>
</>
</FormProvider>
);
};

View File

@ -0,0 +1,160 @@
import KeycloakAdminClient from "@keycloak/keycloak-admin-client";
import { FormErrorText } from "@keycloak/keycloak-ui-shared";
import {
Alert,
Button,
FormHelperText,
InputGroup,
InputGroupItem,
TextInput,
} from "@patternfly/react-core";
import { GlobeRouteIcon } from "@patternfly/react-icons";
import { useEffect, useRef } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useRealm } from "../../../context/realm-context/RealmContext";
import { i18n } from "../../../i18n/i18n";
import { beerify, debeerify } from "../../../util";
import useToggle from "../../../utils/useToggle";
import { AddTranslationsDialog } from "./AddTranslationsDialog";
export type TranslationForm = {
locale: string;
value: string;
};
export type Translation = Record<string, TranslationForm[]>;
export type Translations = {
translation: Translation;
};
type SaveTranslationsProps = {
adminClient: KeycloakAdminClient;
realmName: string;
translationsData: Translations;
};
export const saveTranslations = async ({
adminClient,
realmName,
translationsData,
}: SaveTranslationsProps) => {
await Promise.all(
Object.entries(translationsData.translation)
.map(([key, translation]) =>
translation
.filter((translation) => translation.value.trim() !== "")
.map((translation) =>
adminClient.realms.addLocalization(
{
realm: realmName,
selectedLocale: translation.locale,
key: debeerify(key),
},
translation.value,
),
),
)
.flat(),
);
await i18n.reloadResources();
};
type TranslatableFieldProps = {
attributeName: string;
prefix: string;
fieldName: string;
};
export const TranslatableField = ({
attributeName,
prefix,
fieldName,
}: TranslatableFieldProps) => {
const { t } = useTranslation();
const { realmRepresentation: realm } = useRealm();
const { register, control, setValue, setError, getFieldState, clearErrors } =
useFormContext();
const [open, toggle] = useToggle();
const value = useWatch({ control, name: attributeName });
const key = `${prefix}.${value}`;
const translationPrefix = `translation.${beerify(key)}`;
const requiredTranslationName = `${translationPrefix}.0.value`;
const requiredTranslationValue = useWatch({
control,
name: requiredTranslationName,
});
const ref = useRef(requiredTranslationName);
useEffect(() => {
if (realm?.internationalizationEnabled) {
clearErrors(ref.current);
if (!requiredTranslationValue && t(key) === key) {
setError(requiredTranslationName, {
message: t("required"),
});
}
ref.current = requiredTranslationName;
if (value !== "") {
setValue(fieldName, `\${${prefix}.${value}}`);
} else {
setValue(fieldName, "");
}
}
}, [value, requiredTranslationValue]);
return (
<>
{open && (
<AddTranslationsDialog
translationKey={`${prefix}.${value}`}
fieldName={fieldName}
toggleDialog={toggle}
/>
)}
<InputGroup>
<InputGroupItem isFill>
<TextInput
id={`kc-attribute-${fieldName}`}
data-testid={`attributes-${fieldName}`}
isDisabled={realm?.internationalizationEnabled}
{...register(fieldName)}
/>
</InputGroupItem>
{realm?.internationalizationEnabled && (
<InputGroupItem>
<Button
variant="link"
className="pf-m-plain"
data-testid={`addAttribute${fieldName}TranslationBtn`}
aria-label={t("addAttributeTranslation", { fieldName })}
onClick={toggle}
icon={<GlobeRouteIcon />}
/>
</InputGroupItem>
)}
</InputGroup>
{realm?.internationalizationEnabled && (
<FormHelperText>
<Alert
variant="info"
isInline
isPlain
title={t("addTranslationsModalSubTitle", {
fieldName: t(fieldName),
})}
/>
{getFieldState(`${translationPrefix}.0.value`).error && (
<FormErrorText message={t("required")} />
)}
</FormHelperText>
)}
</>
);
};