mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-09 23:12:06 -03:30
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:
parent
86c475b9fc
commit
d65f896d82
@ -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";
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user